This commit is contained in:
Christian Gunderman
2026-04-13 19:44:05 -07:00
parent 44d8db20c8
commit 6621bd88c8
9 changed files with 119 additions and 69 deletions
+7 -2
View File
@@ -1,11 +1,16 @@
{
"experimental": {
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true
},
"general": {
"devtools": true
"devtools": true,
"plan": {
"enabled": true
}
},
"agents": {
"overrides": {}
}
}
+33 -18
View File
@@ -5,9 +5,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
TestRig,
} from './test-helper.js';
import { TestRig } from './test-helper.js';
describe('shell-parity', () => {
let rig: TestRig;
@@ -19,20 +17,29 @@ describe('shell-parity', () => {
afterEach(async () => await rig.cleanup());
it('should use run_shell_command for replace when sandboxing is enabled', async () => {
await rig.setup('should use run_shell_command for replace when sandboxing is enabled', {
settings: {
security: { toolSandboxing: true },
await rig.setup(
'should use run_shell_command for replace when sandboxing is enabled',
{
settings: {
security: { toolSandboxing: true },
},
},
});
);
rig.createFile('test.ts', 'const foo = "bar";');
// We expect the model to use run_shell_command because edit/replace/write_file are filtered out.
const result = await rig.run({
await rig.run({
args: `Replace "bar" with "baz" in test.ts`,
});
// Verify forbidden tools were NOT used
const forbiddenTools = ['grep_search', 'replace', 'write_file', 'edit', 'read_file'];
const forbiddenTools = [
'grep_search',
'replace',
'write_file',
'edit',
'read_file',
];
const toolLogs = rig.readToolLogs();
const usedForbidden = toolLogs.filter((t) =>
forbiddenTools.includes(t.toolRequest.name),
@@ -55,11 +62,14 @@ describe('shell-parity', () => {
});
it('should use run_shell_command for search when sandboxing is enabled', async () => {
await rig.setup('should use run_shell_command for search when sandboxing is enabled', {
settings: {
security: { toolSandboxing: true },
await rig.setup(
'should use run_shell_command for search when sandboxing is enabled',
{
settings: {
security: { toolSandboxing: true },
},
},
});
);
rig.createFile('search-me.txt', 'target-string');
await rig.run({
@@ -68,7 +78,9 @@ describe('shell-parity', () => {
// Verify grep_search was NOT used
const toolLogs = rig.readToolLogs();
const usedGrep = toolLogs.filter((t) => t.toolRequest.name === 'grep_search');
const usedGrep = toolLogs.filter(
(t) => t.toolRequest.name === 'grep_search',
);
expect(usedGrep).toHaveLength(0);
// Verify run_shell_command was used
@@ -80,11 +92,14 @@ describe('shell-parity', () => {
});
it('should use run_shell_command for read when sandboxing is enabled', async () => {
await rig.setup('should use run_shell_command for read when sandboxing is enabled', {
settings: {
security: { toolSandboxing: true },
await rig.setup(
'should use run_shell_command for read when sandboxing is enabled',
{
settings: {
security: { toolSandboxing: true },
},
},
});
);
rig.createFile('read-me.txt', 'hello world');
const result = await rig.run({
@@ -6,6 +6,12 @@ Narrow Container
"
`;
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 2`] = `
"─────────────────────────
Narrow Container
"
`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
"────────────────────
Very Long Header Ti…
+4 -1
View File
@@ -42,7 +42,10 @@ export function mapToDisplay(
if (call.status === CoreToolCallStatus.Error) {
description = JSON.stringify(call.request.args);
} else {
description = typeof call.invocation.getDisplayTitle === 'function' ? call.invocation.getDisplayTitle() : call.invocation.getDescription();
description =
typeof call.invocation.getDisplayTitle === 'function'
? call.invocation.getDisplayTitle()
: call.invocation.getDescription();
renderOutputAsMarkdown = call.tool.isOutputMarkdown;
}
+4 -1
View File
@@ -1026,7 +1026,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
const invocation = tool.build(args);
// Prefer getDisplayTitle if it differs, otherwise fallback to getDescription.
// This ensures the timeline ("Shell (Replace)") matches the confirmation dialog.
description = typeof invocation.getDisplayTitle === 'function' ? invocation.getDisplayTitle() : invocation.getDescription();
description =
typeof invocation.getDisplayTitle === 'function'
? invocation.getDisplayTitle()
: invocation.getDescription();
}
} catch {
// Ignore errors during formatting for activity emission
+4 -1
View File
@@ -1032,7 +1032,10 @@ export class GeminiChat {
let description: string | undefined = undefined;
if ('invocation' in call && call.invocation) {
description = typeof call.invocation.getDisplayTitle === 'function' ? call.invocation.getDisplayTitle() : call.invocation.getDescription();
description =
typeof call.invocation.getDisplayTitle === 'function'
? call.invocation.getDisplayTitle()
: call.invocation.getDescription();
}
return {
+16 -8
View File
@@ -151,6 +151,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
return `Shell (Write File)`;
case FileOperationType.READ:
return `Shell (Read File)`;
default:
break;
}
}
return this.params.command;
@@ -218,7 +220,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
protected override async getConfirmationDetails(
abortSignal: AbortSignal,
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
const command = stripShellWrapper(this.params.command);
const inferred = inferFileOperation(this.params.command);
@@ -244,25 +246,29 @@ export class ShellToolInvocation extends BaseToolInvocation<
inferred.type === FileOperationType.EDIT &&
originalContent !== null
) {
if (inferred.metadata?.['sedExpression']) {
if (typeof inferred.metadata?.['sedExpression'] === 'string') {
newContent = this.simulateSed(
originalContent,
inferred.metadata['sedExpression'] as string,
inferred.metadata['sedExpression'],
);
} else if (
inferred.metadata?.['oldString'] &&
inferred.metadata?.['newString']
typeof inferred.metadata?.['oldString'] === 'string' &&
typeof inferred.metadata?.['newString'] === 'string'
) {
newContent = this.simulatePsReplace(
originalContent,
inferred.metadata['oldString'] as string,
inferred.metadata['newString'] as string,
inferred.metadata['oldString'],
inferred.metadata['newString'],
);
}
}
if (newContent !== undefined && originalContent !== null) {
const fileDiff = Diff.createPatch(filePath, originalContent, newContent);
const fileDiff = Diff.createPatch(
filePath,
originalContent,
newContent,
);
const editDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: this.getDisplayTitle(),
@@ -511,6 +517,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
operation = FileOperation.UPDATE;
canonicalToolName = WRITE_FILE_TOOL_NAME;
break;
default:
break;
}
if (operation) {
+38 -36
View File
@@ -606,40 +606,42 @@ describe('resolveExecutable', () => {
});
});
describe('inferFileOperation', () => {
it('should infer sed -i as an EDIT operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("sed -i 's/foo/bar/g' test.ts");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.EDIT);
expect(result?.filePath).toBe('test.ts');
expect(result?.metadata?.['sedExpression']).toBe('s/foo/bar/g');
});
it('should infer echo redirection as a WRITE operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("echo 'hello' > hello.txt");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.WRITE);
expect(result?.filePath).toBe('hello.txt');
});
it('should infer grep as a SEARCH operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("grep 'pattern' file.txt");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.SEARCH);
expect(result?.filePath).toBe('file.txt');
expect(result?.metadata?.['pattern']).toBe('pattern');
});
it('should infer PowerShell -replace as an EDIT operation', () => {
mockPlatform.mockReturnValue('win32');
const result = inferFileOperation("(Get-Content file.txt) -replace 'a', 'b' | Set-Content file.txt");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.EDIT);
expect(result?.filePath).toBe('file.txt');
expect(result?.metadata?.['oldString']).toBe('a');
expect(result?.metadata?.['newString']).toBe('b');
});
describe('inferFileOperation', () => {
it('should infer sed -i as an EDIT operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("sed -i 's/foo/bar/g' test.ts");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.EDIT);
expect(result?.filePath).toBe('test.ts');
expect(result?.metadata?.['sedExpression']).toBe('s/foo/bar/g');
});
it('should infer echo redirection as a WRITE operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("echo 'hello' > hello.txt");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.WRITE);
expect(result?.filePath).toBe('hello.txt');
});
it('should infer grep as a SEARCH operation', () => {
mockPlatform.mockReturnValue('linux');
const result = inferFileOperation("grep 'pattern' file.txt");
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.SEARCH);
expect(result?.filePath).toBe('file.txt');
expect(result?.metadata?.['pattern']).toBe('pattern');
});
it('should infer PowerShell -replace as an EDIT operation', () => {
mockPlatform.mockReturnValue('win32');
const result = inferFileOperation(
"(Get-Content file.txt) -replace 'a', 'b' | Set-Content file.txt",
);
expect(result).toBeDefined();
expect(result?.type).toBe(FileOperationType.EDIT);
expect(result?.filePath).toBe('file.txt');
expect(result?.metadata?.['oldString']).toBe('a');
expect(result?.metadata?.['newString']).toBe('b');
});
});
+7 -2
View File
@@ -835,7 +835,9 @@ export function inferFileOperation(
}
// ... | tee file
const teeMatch = stripped.match(/\|\s*tee\s+(?:-a\s+)?['"]?([^'"]+)['"]?\s*$/);
const teeMatch = stripped.match(
/\|\s*tee\s+(?:-a\s+)?['"]?([^'"]+)['"]?\s*$/,
);
if (teeMatch) {
return {
type: FileOperationType.WRITE,
@@ -874,7 +876,10 @@ export function inferFileOperation(
return {
type: FileOperationType.EDIT,
filePath: psReplaceMatch[1].trim(),
metadata: { oldString: psReplaceMatch[2], newString: psReplaceMatch[3] },
metadata: {
oldString: psReplaceMatch[2],
newString: psReplaceMatch[3],
},
};
}