mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 17:27:54 +00:00
Merge branch 'main' into feat/ide-companion-discovery
This commit is contained in:
@@ -287,7 +287,7 @@ export interface ConfigParameters {
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: TelemetrySettings;
|
||||
gitCoAuthor?: GitCoAuthorSettings;
|
||||
gitCoAuthor?: boolean;
|
||||
usageStatisticsEnabled?: boolean;
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
@@ -534,9 +534,9 @@ export class Config {
|
||||
useCollector: params.telemetry?.useCollector,
|
||||
};
|
||||
this.gitCoAuthor = {
|
||||
enabled: params.gitCoAuthor?.enabled ?? true,
|
||||
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
|
||||
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
|
||||
enabled: params.gitCoAuthor ?? true,
|
||||
name: 'Qwen-Coder',
|
||||
email: 'qwen-coder@alibabacloud.com',
|
||||
};
|
||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||
|
||||
@@ -741,9 +741,12 @@ export class Config {
|
||||
/**
|
||||
* Starts a new session and resets session-scoped services.
|
||||
*/
|
||||
startNewSession(sessionId?: string): string {
|
||||
startNewSession(
|
||||
sessionId?: string,
|
||||
sessionData?: ResumedSessionData,
|
||||
): string {
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = undefined;
|
||||
this.sessionData = sessionData;
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
|
||||
@@ -151,8 +151,7 @@ describe('BaseLlmClient', () => {
|
||||
contents: defaultOptions.contents,
|
||||
config: {
|
||||
abortSignal: defaultOptions.abortSignal,
|
||||
temperature: 0,
|
||||
topP: 1,
|
||||
topP: 0.8,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
@@ -189,7 +188,7 @@ describe('BaseLlmClient', () => {
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
temperature: 0.8,
|
||||
topP: 1, // Default should remain if not overridden
|
||||
topP: 0.8, // Default should remain if not overridden
|
||||
topK: 10,
|
||||
tools: expect.any(Array),
|
||||
}),
|
||||
|
||||
@@ -66,8 +66,7 @@ export interface GenerateJsonOptions {
|
||||
export class BaseLlmClient {
|
||||
// Default configuration for utility tasks
|
||||
private readonly defaultUtilityConfig: GenerateContentConfig = {
|
||||
temperature: 0,
|
||||
topP: 1,
|
||||
topP: 0.8,
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -2310,7 +2310,7 @@ ${JSON.stringify(
|
||||
abortSignal,
|
||||
systemInstruction: getCoreSystemPrompt(''),
|
||||
temperature: 0.5,
|
||||
topP: 1,
|
||||
topP: 0.8,
|
||||
},
|
||||
contents,
|
||||
},
|
||||
|
||||
@@ -94,8 +94,7 @@ const MAX_TURNS = 100;
|
||||
export class GeminiClient {
|
||||
private chat?: GeminiChat;
|
||||
private readonly generateContentConfig: GenerateContentConfig = {
|
||||
temperature: 0,
|
||||
topP: 1,
|
||||
topP: 0.8,
|
||||
};
|
||||
private sessionTurnCount = 0;
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
||||
};
|
||||
proxy?: string | undefined;
|
||||
userAgent?: string;
|
||||
// Schema compliance mode for tool definitions
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
};
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
|
||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import {
|
||||
convertSchema,
|
||||
type SchemaComplianceMode,
|
||||
} from '../../utils/schemaConverter.js';
|
||||
|
||||
/**
|
||||
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
||||
*/
|
||||
export class OpenAIContentConverter {
|
||||
private model: string;
|
||||
private schemaCompliance: SchemaComplianceMode;
|
||||
private streamingToolCallParser: StreamingToolCallParser =
|
||||
new StreamingToolCallParser();
|
||||
|
||||
constructor(model: string) {
|
||||
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||
this.model = model;
|
||||
this.schemaCompliance = schemaCompliance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters) {
|
||||
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||
}
|
||||
|
||||
openAITools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct configuration', () => {
|
||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||
'test-model',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
|
||||
this.client = this.config.provider.buildClient();
|
||||
this.converter = new OpenAIContentConverter(
|
||||
this.contentGeneratorConfig.model,
|
||||
this.contentGeneratorConfig.schemaCompliance,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => {
|
||||
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
|
||||
});
|
||||
|
||||
it('should return parent process info if grandparent lookup fails', async () => {
|
||||
it('should return shell process info if grandparent lookup fails', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
|
||||
@@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => {
|
||||
});
|
||||
|
||||
describe('on Windows', () => {
|
||||
it('should traverse up and find the great-grandchild of the root process', async () => {
|
||||
it('should return great-grandparent process using heuristic', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[
|
||||
1000,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
900,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
700,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
|
||||
},
|
||||
],
|
||||
]);
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid));
|
||||
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 800,
|
||||
Name: 'powershell.exe',
|
||||
CommandLine: 'powershell.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 800,
|
||||
ParentProcessId: 700,
|
||||
Name: 'code.exe',
|
||||
CommandLine: 'code.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 700,
|
||||
ParentProcessId: 0,
|
||||
Name: 'wininit.exe',
|
||||
CommandLine: 'wininit.exe',
|
||||
},
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe)
|
||||
// ancestors = [1000, 900, 800, 700], length = 4
|
||||
// Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
|
||||
});
|
||||
|
||||
it('should handle non-existent process gracefully', async () => {
|
||||
it('should handle empty process list gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '[]' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
// Should return current pid and empty command because process not found in map
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON output gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
|
||||
}); // Fallback call
|
||||
mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle PowerShell errors without crashing the process chain', async () => {
|
||||
it('should return last ancestor if chain is too short', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const processInfoMap = new Map([
|
||||
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
|
||||
[
|
||||
1001,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
|
||||
},
|
||||
],
|
||||
[
|
||||
800,
|
||||
{
|
||||
stdout:
|
||||
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock the process.pid to test traversal with missing processes
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1001,
|
||||
configurable: true,
|
||||
});
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 0,
|
||||
Name: 'explorer.exe',
|
||||
CommandLine: 'explorer.exe',
|
||||
},
|
||||
];
|
||||
|
||||
mockedExec.mockImplementation((command: string) => {
|
||||
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1], 10);
|
||||
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
|
||||
mockedExec.mockImplementation((file: string, _args: string[]) => {
|
||||
if (file === 'powershell') {
|
||||
return Promise.resolve({ stdout: JSON.stringify(processes) });
|
||||
}
|
||||
return Promise.reject(new Error('Invalid command for mock'));
|
||||
return Promise.resolve({ stdout: '' });
|
||||
});
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Should return the current process command since traversal continues despite missing processes
|
||||
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
|
||||
|
||||
// Reset process.pid
|
||||
Object.defineProperty(process, 'pid', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial JSON data with defaults', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
|
||||
}); // Get grandparent info
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
|
||||
// ancestors = [1000, 900], length = 2 (< 3)
|
||||
// Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe)
|
||||
expect(result).toEqual({ pid: 900, command: 'explorer.exe' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,74 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const MAX_TRAVERSAL_DEPTH = 32;
|
||||
|
||||
/**
|
||||
* Fetches the parent process ID, name, and command for a given process ID.
|
||||
*
|
||||
* @param pid The process ID to inspect.
|
||||
* @returns A promise that resolves to the parent's PID, name, and command.
|
||||
*/
|
||||
async function getProcessInfo(pid: number): Promise<{
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}> {
|
||||
try {
|
||||
const platform = os.platform();
|
||||
if (platform === 'win32') {
|
||||
const powershellCommand = [
|
||||
'$p = Get-CimInstance Win32_Process',
|
||||
`-Filter 'ProcessId=${pid}'`,
|
||||
'-ErrorAction SilentlyContinue;',
|
||||
'if ($p) {',
|
||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
||||
'| ConvertTo-Json',
|
||||
'}',
|
||||
].join(' ');
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
|
||||
const output = stdout.trim();
|
||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
||||
const {
|
||||
Name = '',
|
||||
ParentProcessId = 0,
|
||||
CommandLine = '',
|
||||
} = JSON.parse(output);
|
||||
return {
|
||||
parentPid: ParentProcessId,
|
||||
name: Name,
|
||||
command: CommandLine ?? '',
|
||||
};
|
||||
} else {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
}
|
||||
} catch (_e) {
|
||||
console.debug(`Failed to get process info for pid ${pid}:`, _e);
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
// Only used for Unix systems (macOS and Linux)
|
||||
const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
|
||||
const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
|
||||
const parentPid = parseInt(ppidStr, 10);
|
||||
const command = commandParts.join(' ');
|
||||
return { parentPid, name: path.basename(command), command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Unix-like systems.
|
||||
*
|
||||
@@ -106,15 +60,15 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
} catch {
|
||||
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
||||
}
|
||||
const { command } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command };
|
||||
const { command: ideCommand } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command: ideCommand };
|
||||
}
|
||||
|
||||
if (parentPid <= 1) {
|
||||
break; // Reached the root
|
||||
}
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
} catch (_e) {
|
||||
// Process in chain died
|
||||
break;
|
||||
}
|
||||
@@ -124,50 +78,104 @@ async function getIdeProcessInfoForUnix(): Promise<{
|
||||
return { pid: currentPid, command };
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface RawProcessInfo {
|
||||
ProcessId?: number;
|
||||
ParentProcessId?: number;
|
||||
Name?: string;
|
||||
CommandLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Windows.
|
||||
*
|
||||
* The strategy is to find the great-grandchild of the root process.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
* Fetches the entire process table on Windows.
|
||||
*/
|
||||
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
try {
|
||||
const powershellCommand =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell',
|
||||
['-NoProfile', '-NonInteractive', '-Command', powershellCommand],
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
let processes: RawProcessInfo | RawProcessInfo[];
|
||||
try {
|
||||
processes = JSON.parse(stdout);
|
||||
} catch (_e) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
if (!Array.isArray(processes)) {
|
||||
processes = [processes];
|
||||
}
|
||||
|
||||
for (const p of processes) {
|
||||
if (p && typeof p.ProcessId === 'number') {
|
||||
processMap.set(p.ProcessId, {
|
||||
pid: p.ProcessId,
|
||||
parentPid: p.ParentProcessId || 0,
|
||||
name: p.Name || '',
|
||||
command: p.CommandLine || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or error handling if PowerShell fails
|
||||
}
|
||||
return processMap;
|
||||
}
|
||||
|
||||
async function getIdeProcessInfoForWindows(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
let currentPid = process.pid;
|
||||
let previousPid = process.pid;
|
||||
// Fetch the entire process table in one go.
|
||||
const processMap = await getProcessTableWindows();
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||
try {
|
||||
const { parentPid } = await getProcessInfo(currentPid);
|
||||
const myPid = process.pid;
|
||||
const myProc = processMap.get(myPid);
|
||||
|
||||
if (parentPid > 0) {
|
||||
try {
|
||||
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||
if (grandParentPid === 0) {
|
||||
// We've found the grandchild of the root (`currentPid`). The IDE
|
||||
// process is its child, which we've stored in `previousPid`.
|
||||
const { command } = await getProcessInfo(previousPid);
|
||||
return { pid: previousPid, command };
|
||||
}
|
||||
} catch {
|
||||
// getting grandparent failed, proceed
|
||||
}
|
||||
}
|
||||
if (!myProc) {
|
||||
// Fallback: return current process info if snapshot fails
|
||||
return { pid: myPid, command: '' };
|
||||
}
|
||||
|
||||
if (parentPid <= 0) {
|
||||
break; // Reached the root
|
||||
}
|
||||
previousPid = currentPid;
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
// Process in chain died
|
||||
// Perform tree traversal in memory
|
||||
const ancestors: ProcessInfo[] = [];
|
||||
let curr: ProcessInfo | undefined = myProc;
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
|
||||
ancestors.push(curr);
|
||||
|
||||
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
|
||||
// Parent process not in map, stop traversal
|
||||
break;
|
||||
}
|
||||
curr = processMap.get(curr.parentPid);
|
||||
}
|
||||
const { command } = await getProcessInfo(currentPid);
|
||||
return { pid: currentPid, command };
|
||||
|
||||
// Use heuristic: return the great-grandparent (ancestors[length-3])
|
||||
if (ancestors.length >= 3) {
|
||||
const target = ancestors[ancestors.length - 3];
|
||||
return { pid: target.pid, command: target.command };
|
||||
} else if (ancestors.length > 0) {
|
||||
const target = ancestors[ancestors.length - 1];
|
||||
return { pid: target.pid, command: target.command };
|
||||
}
|
||||
|
||||
return { pid: myPid, command: myProc.command };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -608,6 +608,36 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git commit with combined short flags like -am', async () => {
|
||||
const command = 'git commit -am "Add feature"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify non-git commands', async () => {
|
||||
const command = 'npm install';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
@@ -768,6 +798,69 @@ describe('ShellTool', () => {
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author when git commit is prefixed with cd command', async () => {
|
||||
const command = 'cd /tmp/test && git commit -m "Test commit"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author to git commit with multi-line message', async () => {
|
||||
const command = `git commit -m "Fix bug
|
||||
|
||||
This is a detailed description
|
||||
spanning multiple lines"`;
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -334,13 +334,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
private addCoAuthorToGitCommit(command: string): string {
|
||||
// Check if co-author feature is enabled
|
||||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||||
|
||||
if (!gitCoAuthorSettings.enabled) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Check if this is a git commit command
|
||||
const gitCommitPattern = /^git\s+commit/;
|
||||
if (!gitCommitPattern.test(command.trim())) {
|
||||
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
|
||||
const gitCommitPattern = /\bgit\s+commit\b/;
|
||||
if (!gitCommitPattern.test(command)) {
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -349,15 +350,27 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
|
||||
// Handle different git commit patterns
|
||||
// Match -m "message" or -m 'message'
|
||||
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
|
||||
const match = command.match(messagePattern);
|
||||
// Handle different git commit patterns:
|
||||
// Match -m "message" or -m 'message', including combined flags like -am
|
||||
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
|
||||
//
|
||||
// Pattern breakdown:
|
||||
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
|
||||
// \s+ matches whitespace after the flag
|
||||
// [^"\\] matches any char except double-quote and backslash
|
||||
// \\. matches escape sequences like \" or \\
|
||||
// (?:...|...)* matches normal chars or escapes, repeated
|
||||
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
|
||||
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
|
||||
const doubleMatch = command.match(doubleQuotePattern);
|
||||
const singleMatch = command.match(singleQuotePattern);
|
||||
const match = doubleMatch ?? singleMatch;
|
||||
const quote = doubleMatch ? '"' : "'";
|
||||
|
||||
if (match) {
|
||||
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
|
||||
const [fullMatch, prefix, existingMessage] = match;
|
||||
const newMessage = existingMessage + coAuthor;
|
||||
const replacement = prefix + quote + newMessage + closingQuote;
|
||||
const replacement = prefix + quote + newMessage + quote;
|
||||
|
||||
return command.replace(fullMatch, replacement);
|
||||
}
|
||||
|
||||
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertSchema } from './schemaConverter.js';
|
||||
|
||||
describe('convertSchema', () => {
|
||||
describe('mode: auto (default)', () => {
|
||||
it('should preserve type arrays', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve items array (tuples)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve mixed enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
exclusiveMinimum: 10,
|
||||
type: 'number',
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode: openapi_30 (strict)', () => {
|
||||
it('should convert type arrays to nullable', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
const expected = { type: 'string', nullable: true };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should fallback to first type for non-nullable arrays', () => {
|
||||
const input = { type: ['string', 'number'] };
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert const to enum', () => {
|
||||
const input = { const: 'foo' };
|
||||
const expected = { enum: ['foo'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert exclusiveMinimum number to boolean', () => {
|
||||
const input = { type: 'number', exclusiveMinimum: 10 };
|
||||
const expected = {
|
||||
type: 'number',
|
||||
minimum: 10,
|
||||
exclusiveMinimum: true,
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert nested objects recursively', () => {
|
||||
const input = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 },
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
maximum: 5,
|
||||
exclusiveMaximum: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
const expected = { enum: ['1', '2', '3'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove tuple items (array of schemas)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
const expected = { type: 'array' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
$id: '#foo',
|
||||
type: 'string',
|
||||
default: 'bar',
|
||||
dependencies: { foo: ['bar'] },
|
||||
patternProperties: { '^foo': { type: 'string' } },
|
||||
};
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/core/src/utils/schemaConverter.ts
Normal file
135
packages/core/src/utils/schemaConverter.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility for converting JSON Schemas to be compatible with different LLM providers.
|
||||
* Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to
|
||||
* OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API.
|
||||
*/
|
||||
|
||||
export type SchemaComplianceMode = 'auto' | 'openapi_30';
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema to be compatible with the specified compliance mode.
|
||||
*/
|
||||
export function convertSchema(
|
||||
schema: Record<string, unknown>,
|
||||
mode: SchemaComplianceMode = 'auto',
|
||||
): Record<string, unknown> {
|
||||
if (mode === 'openapi_30') {
|
||||
return toOpenAPI30(schema);
|
||||
}
|
||||
|
||||
// Default ('auto') mode now does nothing.
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Modern JSON Schema to OpenAPI 3.0 Schema Object.
|
||||
* Attempts to preserve semantics where possible through transformations.
|
||||
*/
|
||||
function toOpenAPI30(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
const convert = (obj: unknown): unknown => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convert);
|
||||
}
|
||||
|
||||
const source = obj as Record<string, unknown>;
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
// 1. Type Handling
|
||||
if (Array.isArray(source['type'])) {
|
||||
const types = source['type'] as string[];
|
||||
// Handle ["string", "null"] pattern common in modern schemas
|
||||
if (types.length === 2 && types.includes('null')) {
|
||||
target['type'] = types.find((t) => t !== 'null');
|
||||
target['nullable'] = true;
|
||||
} else {
|
||||
// Fallback for other unions: take the first non-null type
|
||||
// OpenAPI 3.0 doesn't support type arrays.
|
||||
// Ideal fix would be anyOf, but simple fallback is safer for now.
|
||||
target['type'] = types[0];
|
||||
}
|
||||
} else if (source['type'] !== undefined) {
|
||||
target['type'] = source['type'];
|
||||
}
|
||||
|
||||
// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
|
||||
if (source['const'] !== undefined) {
|
||||
target['enum'] = [source['const']];
|
||||
delete target['const'];
|
||||
}
|
||||
|
||||
// 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean)
|
||||
// exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true
|
||||
if (typeof source['exclusiveMinimum'] === 'number') {
|
||||
target['minimum'] = source['exclusiveMinimum'];
|
||||
target['exclusiveMinimum'] = true;
|
||||
}
|
||||
if (typeof source['exclusiveMaximum'] === 'number') {
|
||||
target['maximum'] = source['exclusiveMaximum'];
|
||||
target['exclusiveMaximum'] = true;
|
||||
}
|
||||
|
||||
// 4. Array Items (Tuple -> Single Schema)
|
||||
// OpenAPI 3.0 items must be a schema object, not an array of schemas
|
||||
if (Array.isArray(source['items'])) {
|
||||
// Tuple support is tricky.
|
||||
// Best effort: Use the first item's schema as a generic array type
|
||||
// or convert to an empty object (any type) if mixed.
|
||||
// For now, we'll strip it to allow validation to pass (accepts any items)
|
||||
// This matches the legacy behavior but is explicit.
|
||||
// Ideally, we could use `oneOf` on the items if we wanted to be stricter.
|
||||
delete target['items'];
|
||||
} else if (
|
||||
typeof source['items'] === 'object' &&
|
||||
source['items'] !== null
|
||||
) {
|
||||
target['items'] = convert(source['items']);
|
||||
}
|
||||
|
||||
// 5. Enum Stringification
|
||||
// Gemini strictly requires enums to be strings
|
||||
if (Array.isArray(source['enum'])) {
|
||||
target['enum'] = source['enum'].map(String);
|
||||
}
|
||||
|
||||
// 6. Recursively process other properties
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
// Skip fields we've already handled or want to remove
|
||||
if (
|
||||
key === 'type' ||
|
||||
key === 'const' ||
|
||||
key === 'exclusiveMinimum' ||
|
||||
key === 'exclusiveMaximum' ||
|
||||
key === 'items' ||
|
||||
key === 'enum' ||
|
||||
key === '$schema' ||
|
||||
key === '$id' ||
|
||||
key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types
|
||||
key === 'dependencies' ||
|
||||
key === 'patternProperties'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = convert(value);
|
||||
}
|
||||
|
||||
// Preserve default if it doesn't conflict (simple pass-through)
|
||||
// if (source['default'] !== undefined) {
|
||||
// target['default'] = source['default'];
|
||||
// }
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
return convert(schema) as Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user