feat: human-readable permission labels, deny rule feedback, and multi-dir search tests

- Add buildHumanReadableRuleLabel() to convert raw permission rules into
  natural-language descriptions for the 'Always Allow' UI options
- Add PermissionManager.findMatchingDenyRule() to surface which deny rule
  caused a tool to be blocked, improving error messages in coreToolScheduler
- Update ToolConfirmationMessage to use friendly labels with i18n support
- Add comprehensive tests for new permission features and multi-directory
  search in glob, grep, and ripGrep tools
- Fix integration test for tool-control allowedTools configuration
This commit is contained in:
LaZzyMan 2026-03-24 19:47:07 +08:00
parent 8c31775573
commit a5a8ec5d67
14 changed files with 622 additions and 19 deletions

View file

@ -1047,7 +1047,11 @@ export default {
"Ausführung erlauben von: '{{command}}'?",
'Yes, allow always ...': 'Ja, immer erlauben ...',
'Always allow in this project': 'In diesem Projekt immer erlauben',
'Always allow {{action}} in this project':
'{{action}} in diesem Projekt immer erlauben',
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Always allow {{action}} for this user':
'{{action}} für diesen Benutzer immer erlauben',
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',

View file

@ -1103,7 +1103,11 @@ export default {
"Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?",
'Yes, allow always ...': 'Yes, allow always ...',
'Always allow in this project': 'Always allow in this project',
'Always allow {{action}} in this project':
'Always allow {{action}} in this project',
'Always allow for this user': 'Always allow for this user',
'Always allow {{action}} for this user':
'Always allow {{action}} for this user',
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
'Yes, and manually approve edits': 'Yes, and manually approve edits',
'No, keep planning (esc)': 'No, keep planning (esc)',

View file

@ -786,7 +786,10 @@ export default {
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Always allow in this project': 'このプロジェクトで常に許可',
'Always allow {{action}} in this project':
'このプロジェクトで{{action}}を常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',

View file

@ -1054,7 +1054,11 @@ export default {
"Permitir a execução de: '{{command}}'?",
'Yes, allow always ...': 'Sim, permitir sempre ...',
'Always allow in this project': 'Sempre permitir neste projeto',
'Always allow {{action}} in this project':
'Sempre permitir {{action}} neste projeto',
'Always allow for this user': 'Sempre permitir para este usuário',
'Always allow {{action}} for this user':
'Sempre permitir {{action}} para este usuário',
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
'No, keep planning (esc)': 'Não, continuar planejando (esc)',

View file

@ -979,7 +979,11 @@ export default {
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
'Yes, allow always ...': 'Да, всегда разрешать ...',
'Always allow in this project': 'Всегда разрешать в этом проекте',
'Always allow {{action}} in this project':
'Всегда разрешать {{action}} в этом проекте',
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Always allow {{action}} for this user':
'Всегда разрешать {{action}} для этого пользователя',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',

View file

@ -1044,7 +1044,9 @@ export default {
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'",
'Yes, allow always ...': '是,总是允许 ...',
'Always allow in this project': '在本项目中总是允许',
'Always allow {{action}} in this project': '在本项目中总是允许{{action}}',
'Always allow for this user': '对该用户总是允许',
'Always allow {{action}} for this user': '对该用户总是允许{{action}}',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',

View file

@ -17,7 +17,11 @@ import type {
Config,
EditorType,
} from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import {
IdeClient,
ToolConfirmationOutcome,
buildHumanReadableRuleLabel,
} from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
@ -243,16 +247,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = executionProps.permissionRules?.length
? ` [${executionProps.permissionRules.join(', ')}]`
const friendlyLabel = executionProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(executionProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
@ -324,18 +336,26 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel =
const friendlyLabel =
'permissionRules' in infoProps &&
(infoProps as { permissionRules?: string[] }).permissionRules?.length
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
? ` ${buildHumanReadableRuleLabel((infoProps as { permissionRules?: string[] }).permissionRules!)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
@ -401,16 +421,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = mcpProps.permissionRules?.length
? ` [${mcpProps.permissionRules.join(', ')}]`
const friendlyLabel = mcpProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(mcpProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});

View file

@ -701,7 +701,13 @@ export class CoreToolScheduler {
// This check should happen before registry lookup to provide a clear permission error
const pm = this.config.getPermissionManager?.();
if (pm && !pm.isToolEnabled(reqInfo.name)) {
const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
const matchingRule = pm.findMatchingDenyRule({
toolName: reqInfo.name,
});
const ruleInfo = matchingRule
? ` Matching deny rule: "${matchingRule}".`
: '';
const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.${ruleInfo}`;
return {
status: 'error',
request: reqInfo,
@ -914,10 +920,16 @@ export class CoreToolScheduler {
if (finalPermission === 'deny') {
// Hard deny: security violation or PM explicit deny
const denyMessage =
defaultPermission === 'deny'
? `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`
: `Tool "${reqInfo.name}" is denied by permission rules.`;
let denyMessage: string;
if (defaultPermission === 'deny') {
denyMessage = `Tool "${reqInfo.name}" is denied: command substitution is not allowed for security reasons.`;
} else {
const matchingRule = pm?.findMatchingDenyRule(pmCtx);
const ruleInfo = matchingRule
? ` Matching deny rule: "${matchingRule}".`
: '';
denyMessage = `Tool "${reqInfo.name}" is denied by permission rules.${ruleInfo}`;
}
this.setStatusInternal(
reqInfo.callId,
'error',
@ -1002,7 +1014,7 @@ export class CoreToolScheduler {
this.config.getInputFormat() !== InputFormat.STREAM_JSON;
if (shouldAutoDeny) {
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined (non-interactive mode cannot prompt for confirmation).`;
this.setStatusInternal(
reqInfo.callId,
'error',

View file

@ -20,6 +20,7 @@ import {
splitCompoundCommand,
buildPermissionRules,
getRuleDisplayName,
buildHumanReadableRuleLabel,
} from './rule-parser.js';
import { PermissionManager } from './permission-manager.js';
import type { PermissionManagerConfig } from './permission-manager.js';
@ -1519,3 +1520,174 @@ describe('buildPermissionRules', () => {
});
});
});
// ─── buildHumanReadableRuleLabel ─────────────────────────────────────────────
describe('buildHumanReadableRuleLabel', () => {
it('returns empty string for empty rules array', () => {
expect(buildHumanReadableRuleLabel([])).toBe('');
});
it('converts bare Read rule to "read files"', () => {
expect(buildHumanReadableRuleLabel(['Read'])).toBe('read files');
});
it('converts bare Bash rule to "run commands"', () => {
expect(buildHumanReadableRuleLabel(['Bash'])).toBe('run commands');
});
it('converts bare WebSearch rule to "search the web"', () => {
expect(buildHumanReadableRuleLabel(['WebSearch'])).toBe('search the web');
});
it('converts Read with absolute path specifier', () => {
const label = buildHumanReadableRuleLabel(['Read(//Users/mochi/.qwen/**)']);
expect(label).toBe('read files in /Users/mochi/.qwen/');
});
it('converts Read with relative path specifier', () => {
const label = buildHumanReadableRuleLabel(['Read(/src/**)']);
expect(label).toBe('read files in /src/');
});
it('converts Edit with path specifier', () => {
const label = buildHumanReadableRuleLabel(['Edit(//tmp/**)']);
expect(label).toBe('edit files in /tmp/');
});
it('converts Bash with command specifier', () => {
const label = buildHumanReadableRuleLabel(['Bash(git *)']);
expect(label).toBe("run 'git *' commands");
});
it('converts WebFetch with domain specifier', () => {
const label = buildHumanReadableRuleLabel(['WebFetch(github.com)']);
expect(label).toBe('fetch from github.com');
});
it('converts Skill with literal specifier', () => {
const label = buildHumanReadableRuleLabel(['Skill(Explore)']);
expect(label).toBe('use skill "Explore"');
});
it('converts Agent with literal specifier', () => {
const label = buildHumanReadableRuleLabel(['Agent(research)']);
expect(label).toBe('use agent "research"');
});
it('joins multiple rules with commas', () => {
const label = buildHumanReadableRuleLabel([
'Read(//Users/alice/**)',
'Bash(npm *)',
]);
expect(label).toBe("read files in /Users/alice/, run 'npm *' commands");
});
it('handles unknown display names gracefully', () => {
const label = buildHumanReadableRuleLabel(['mcp__server__tool']);
expect(label).toBe('mcp__server__tool');
});
it('handles unknown display name with specifier', () => {
const label = buildHumanReadableRuleLabel(['UnknownCategory(someValue)']);
expect(label).toBe('unknowncategory "someValue"');
});
it('cleans path with /* suffix', () => {
const label = buildHumanReadableRuleLabel(['Read(//home/user/docs/*)']);
expect(label).toBe('read files in /home/user/docs/');
});
it('round-trips from buildPermissionRules for file tool', () => {
const rules = buildPermissionRules({
toolName: 'read_file',
filePath: '/Users/alice/.secrets',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe('read files in /Users/alice/');
});
it('round-trips from buildPermissionRules for shell command', () => {
const rules = buildPermissionRules({
toolName: 'run_shell_command',
command: 'git status',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe("run 'git status' commands");
});
it('round-trips from buildPermissionRules for web fetch', () => {
const rules = buildPermissionRules({
toolName: 'web_fetch',
domain: 'example.com',
});
const label = buildHumanReadableRuleLabel(rules);
expect(label).toBe('fetch from example.com');
});
});
// ─── PermissionManager.findMatchingDenyRule ──────────────────────────────────
describe('PermissionManager.findMatchingDenyRule', () => {
it('returns the raw deny rule string when context matches', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['Bash(rm *)'] }),
);
pm.initialize();
const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'rm -rf /tmp/foo',
});
expect(result).toBe('Bash(rm *)');
});
it('returns undefined when no deny rule matches', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['Bash(rm *)'] }),
);
pm.initialize();
const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'git status',
});
expect(result).toBeUndefined();
});
it('matches session deny rules', () => {
const pm = new PermissionManager(makeConfig());
pm.initialize();
pm.addSessionDenyRule('Read(//secret/**)');
const result = pm.findMatchingDenyRule({
toolName: 'read_file',
filePath: '/secret/key.pem',
});
expect(result).toBe('Read(//secret/**)');
});
it('returns undefined for non-denied tool', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['ShellTool'] }),
);
pm.initialize();
const result = pm.findMatchingDenyRule({ toolName: 'read_file' });
expect(result).toBeUndefined();
});
it('matches bare tool deny rule', () => {
const pm = new PermissionManager(
makeConfig({ permissionsDeny: ['ShellTool'] }),
);
pm.initialize();
const result = pm.findMatchingDenyRule({
toolName: 'run_shell_command',
command: 'echo hello',
});
// rule.raw preserves the original rule string as written in config
expect(result).toBe('ShellTool');
});
});

View file

@ -365,6 +365,43 @@ export class PermissionManager {
return decision !== 'deny';
}
/**
* Find the first deny rule that matches the given context.
* Returns the raw rule string if found, or undefined if no deny rule matches.
*
* Useful for providing user-visible feedback about which rule caused a denial.
*/
findMatchingDenyRule(ctx: PermissionCheckContext): string | undefined {
const { toolName, command, filePath, domain, specifier } = ctx;
const pathCtx: PathMatchContext | undefined =
this.config.getProjectRoot && this.config.getCwd
? {
projectRoot: this.config.getProjectRoot(),
cwd: this.config.getCwd(),
}
: undefined;
const matchArgs = [
toolName,
command,
filePath,
domain,
pathCtx,
specifier,
] as const;
for (const rule of [
...this.sessionRules.deny,
...this.persistentRules.deny,
]) {
if (matchesRule(rule, ...matchArgs)) {
return rule.raw;
}
}
return undefined;
}
// ---------------------------------------------------------------------------
// Shell command helper
// ---------------------------------------------------------------------------

View file

@ -405,6 +405,106 @@ export function buildPermissionRules(ctx: PermissionCheckContext): string[] {
}
}
/**
* Human-readable display names for permission rule categories.
* Maps display name verb phrase for use in "Always allow [verb phrase] in this project".
*/
const DISPLAY_NAME_TO_VERB: Readonly<Record<string, string>> = {
Read: 'read files',
Edit: 'edit files',
Bash: 'run commands',
WebFetch: 'fetch from',
WebSearch: 'search the web',
Agent: 'use agent',
Skill: 'use skill',
SaveMemory: 'save memory',
TodoWrite: 'write todos',
Lsp: 'use LSP',
ExitPlanMode: 'exit plan mode',
};
/**
* Strip the glob suffix (e.g. `/**`) and the leading `//` from an absolute
* path specifier so it reads cleanly in a UI label.
*
* `//Users/mochi/.qwen/**` `/Users/mochi/.qwen/`
* `/src/**` `src/`
*/
function cleanPathSpecifier(specifier: string): string {
let cleaned = specifier;
// Remove trailing glob patterns like /** or /*
cleaned = cleaned.replace(/\/\*\*$/, '/').replace(/\/\*$/, '/');
// Convert rule grammar `//absolute` → `/absolute`
if (cleaned.startsWith('//')) {
cleaned = cleaned.substring(1);
}
// Ensure trailing slash for directories
if (!cleaned.endsWith('/')) {
cleaned += '/';
}
return cleaned;
}
/**
* Build a human-readable label describing what a set of permission rules allow.
*
* Used in "Always Allow" UI options to give users a clear, natural-language
* description instead of raw rule syntax.
*
* Examples:
* `["Read(//Users/mochi/.qwen/**)"]` `"read files in /Users/mochi/.qwen/"`
* `["Bash(git *)"]` `"run 'git *' commands"`
* `["WebFetch(github.com)"]` `"fetch from github.com"`
* `["Read"]` `"read files"`
*
* @param rules - Array of rule strings from buildPermissionRules()
* @returns A human-readable description string
*/
export function buildHumanReadableRuleLabel(rules: string[]): string {
if (!rules.length) return '';
const parts: string[] = [];
for (const rule of rules) {
// Parse "DisplayName(specifier)" or bare "DisplayName"
const parenIdx = rule.indexOf('(');
if (parenIdx === -1) {
// Bare rule like "Read" or "Bash"
const verb = DISPLAY_NAME_TO_VERB[rule] ?? rule.toLowerCase();
parts.push(verb);
continue;
}
const displayName = rule.substring(0, parenIdx);
const specifier = rule.substring(parenIdx + 1, rule.length - 1); // strip parens
const verb = DISPLAY_NAME_TO_VERB[displayName] ?? displayName.toLowerCase();
const canonicalName = Object.entries(CANONICAL_TO_RULE_DISPLAY).find(
([, v]) => v === displayName,
)?.[0];
const kind = canonicalName ? getSpecifierKind(canonicalName) : 'literal';
switch (kind) {
case 'path': {
const cleanPath = cleanPathSpecifier(specifier);
parts.push(`${verb} in ${cleanPath}`);
break;
}
case 'command':
parts.push(`run '${specifier}' commands`);
break;
case 'domain':
parts.push(`${verb} ${specifier}`);
break;
case 'literal':
default:
parts.push(`${verb} "${specifier}"`);
break;
}
}
return parts.join(', ');
}
// ─────────────────────────────────────────────────────────────────────────────
// Shell command matching
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -366,6 +366,87 @@ describe('GlobTool', () => {
});
});
describe('multi-directory workspace', () => {
it('should search across all workspace directories when no path is specified', async () => {
// Create a second workspace directory
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'glob-tool-second-'),
);
await fs.writeFile(path.join(secondDir, '.git'), ''); // Fake git repo
await fs.writeFile(path.join(secondDir, 'extra.txt'), 'extra content');
await fs.writeFile(path.join(secondDir, 'bonus.txt'), 'bonus content');
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
} as unknown as Config;
const multiDirGlobTool = new GlobTool(multiDirConfig);
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = multiDirGlobTool.build(params);
const result = await invocation.execute(abortSignal);
// Should find files from both directories
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
expect(result.llmContent).toContain(path.join(secondDir, 'extra.txt'));
expect(result.llmContent).toContain(path.join(secondDir, 'bonus.txt'));
expect(result.llmContent).toContain('across 2 workspace directories');
await fs.rm(secondDir, { recursive: true, force: true });
});
it('should deduplicate entries across overlapping directories', async () => {
// Use the same directory twice to test deduplication
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [tempRootDir]),
} as unknown as Config;
const multiDirGlobTool = new GlobTool(multiDirConfig);
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = multiDirGlobTool.build(params);
const result = await invocation.execute(abortSignal);
// Should still only have 2 txt files (fileA.txt, FileB.TXT), not doubled
expect(result.llmContent).toContain('Found 2 file(s)');
});
it('should use single directory description when only one workspace dir', async () => {
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('in the workspace directory');
expect(result.llmContent).not.toContain('across');
});
it('should search only the specified path when path is provided (ignoring multi-dir)', async () => {
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'glob-tool-second-'),
);
await fs.writeFile(path.join(secondDir, '.git'), '');
await fs.writeFile(path.join(secondDir, 'other.txt'), 'other');
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
} as unknown as Config;
const multiDirGlobTool = new GlobTool(multiDirConfig);
const params: GlobToolParams = { pattern: '*.txt', path: 'sub' };
const invocation = multiDirGlobTool.build(params);
const result = await invocation.execute(abortSignal);
// Should NOT find files from secondDir
expect(result.llmContent).not.toContain('other.txt');
await fs.rm(secondDir, { recursive: true, force: true });
});
});
describe('ignore file handling', () => {
it('should respect .gitignore files by default', async () => {
await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');

View file

@ -357,6 +357,48 @@ describe('GrepTool', () => {
// Clean up
await fs.rm(secondDir, { recursive: true, force: true });
});
it('should convert relative paths to absolute when searching multiple directories', async () => {
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.writeFile(
path.join(secondDir, 'extra.txt'),
'world content in second dir',
);
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
getTruncateToolOutputThreshold: () => 25000,
getTruncateToolOutputLines: () => 1000,
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
const params: GrepToolParams = { pattern: 'world' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should show "across N workspace directories"
expect(result.llmContent).toContain('across 2 workspace directories');
// File paths from the second directory should be absolute
expect(result.llmContent).toContain(
`File: ${path.resolve(secondDir, 'extra.txt')}`,
);
// File paths from the first directory should also be absolute
expect(result.llmContent).toContain(
`File: ${path.resolve(tempRootDir, 'fileA.txt')}`,
);
await fs.rm(secondDir, { recursive: true, force: true });
});
});
describe('getDescription', () => {

View file

@ -436,6 +436,116 @@ describe('RipGrepTool', () => {
});
});
describe('multi-directory workspace', () => {
it('should search across all workspace directories when no path is specified', async () => {
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.writeFile(
path.join(secondDir, 'extra.txt'),
'hello from second dir',
);
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
} as unknown as Config;
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}${secondDir}/extra.txt:1:hello from second dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'hello' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('across 2 workspace directories');
expect(result.llmContent).toContain('Found 2 matches');
// Verify both paths were passed to runRipgrep
expect(runRipgrep).toHaveBeenCalledWith(
expect.arrayContaining([tempRootDir, secondDir]),
expect.anything(),
);
await fs.rm(secondDir, { recursive: true, force: true });
});
it('should search only specified path when path is given (ignoring multi-dir)', async () => {
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.writeFile(path.join(secondDir, 'other.txt'), 'other content');
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
} as unknown as Config;
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileC.txt:1:another world in sub dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };
const invocation = multiDirGrepTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('in path "sub"');
expect(result.llmContent).not.toContain('across');
await fs.rm(secondDir, { recursive: true, force: true });
});
it('should load .qwenignore from each workspace directory', async () => {
const secondDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'grep-tool-second-'),
);
await fs.writeFile(path.join(secondDir, '.qwenignore'), 'ignored.txt\n');
await fs.writeFile(
path.join(tempRootDir, '.qwenignore'),
'other-ignored.txt\n',
);
const multiDirConfig = {
...mockConfig,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
} as unknown as Config;
const multiDirGrepTool = new RipGrepTool(multiDirConfig);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'test' };
const invocation = multiDirGrepTool.build(params);
await invocation.execute(abortSignal);
// Verify both .qwenignore files were passed
const rgArgs = (runRipgrep as Mock).mock.calls[0][0] as string[];
const ignoreFileArgs = rgArgs.filter(
(a: string, i: number) => i > 0 && rgArgs[i - 1] === '--ignore-file',
);
expect(ignoreFileArgs).toContain(path.join(tempRootDir, '.qwenignore'));
expect(ignoreFileArgs).toContain(path.join(secondDir, '.qwenignore'));
await fs.rm(secondDir, { recursive: true, force: true });
});
});
describe('abort signal handling', () => {
it('should handle AbortSignal during search', async () => {
const controller = new AbortController();