fix: add explicit is_background param for shell tool (#445)

* fix: add explicit background param for shell tool

* fix: explicit param schema

* docs(shelltool): update `is_background` description
This commit is contained in:
Mingholy
2025-08-27 11:32:48 +08:00
committed by GitHub
parent f61a2df519
commit de279b56f3
3 changed files with 255 additions and 41 deletions

View File

@@ -99,24 +99,47 @@ describe('ShellTool', () => {
describe('build', () => {
it('should return an invocation for a valid command', () => {
const invocation = shellTool.build({ command: 'ls -l' });
const invocation = shellTool.build({
command: 'ls -l',
is_background: false,
});
expect(invocation).toBeDefined();
});
it('should throw an error for an empty command', () => {
expect(() => shellTool.build({ command: ' ' })).toThrow(
'Command cannot be empty.',
);
expect(() =>
shellTool.build({ command: ' ', is_background: false }),
).toThrow('Command cannot be empty.');
});
it('should throw an error for a non-existent directory', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(() =>
shellTool.build({ command: 'ls', directory: 'rel/path' }),
shellTool.build({
command: 'ls',
directory: 'rel/path',
is_background: false,
}),
).toThrow(
"Directory 'rel/path' is not a registered workspace directory.",
);
});
it('should include background indicator in description when is_background is true', () => {
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
});
expect(invocation.getDescription()).toContain('[background]');
});
it('should not include background indicator in description when is_background is false', () => {
const invocation = shellTool.build({
command: 'npm test',
is_background: false,
});
expect(invocation.getDescription()).not.toContain('[background]');
});
});
describe('execute', () => {
@@ -141,7 +164,10 @@ describe('ShellTool', () => {
};
it('should wrap command on linux and parse pgrep output', async () => {
const invocation = shellTool.build({ command: 'my-command &' });
const invocation = shellTool.build({
command: 'my-command &',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
@@ -162,9 +188,81 @@ describe('ShellTool', () => {
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
});
it('should add ampersand to command when is_background is true and command does not end with &', async () => {
const invocation = shellTool.build({
command: 'npm start',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not add extra ampersand when is_background is true and command already ends with &', async () => {
const invocation = shellTool.build({
command: 'npm start &',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not add ampersand when is_background is false', async () => {
const invocation = shellTool.build({
command: 'npm test',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({ pid: 54321 });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
await promise;
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not wrap command on windows', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const invocation = shellTool.build({ command: 'dir' });
const invocation = shellTool.build({
command: 'dir',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
rawOutput: Buffer.from(''),
@@ -188,7 +286,10 @@ describe('ShellTool', () => {
it('should format error messages correctly', async () => {
const error = new Error('wrapped command failed');
const invocation = shellTool.build({ command: 'user-command' });
const invocation = shellTool.build({
command: 'user-command',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
error,
@@ -209,15 +310,19 @@ describe('ShellTool', () => {
});
it('should throw an error for invalid parameters', () => {
expect(() => shellTool.build({ command: '' })).toThrow(
'Command cannot be empty.',
);
expect(() =>
shellTool.build({ command: '', is_background: false }),
).toThrow('Command cannot be empty.');
});
it('should throw an error for invalid directory', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(() =>
shellTool.build({ command: 'ls', directory: 'nonexistent' }),
shellTool.build({
command: 'ls',
directory: 'nonexistent',
is_background: false,
}),
).toThrow(
`Directory 'nonexistent' is not a registered workspace directory.`,
);
@@ -231,7 +336,10 @@ describe('ShellTool', () => {
'summarized output',
);
const invocation = shellTool.build({ command: 'ls' });
const invocation = shellTool.build({
command: 'ls',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
output: 'long output',
@@ -264,7 +372,10 @@ describe('ShellTool', () => {
});
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
const invocation = shellTool.build({ command: 'a-command' });
const invocation = shellTool.build({
command: 'a-command',
is_background: false,
});
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
@@ -282,7 +393,10 @@ describe('ShellTool', () => {
});
it('should throttle text output updates', async () => {
const invocation = shellTool.build({ command: 'stream' });
const invocation = shellTool.build({
command: 'stream',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
// First chunk, should be throttled.
@@ -322,7 +436,10 @@ describe('ShellTool', () => {
});
it('should immediately show binary detection message and throttle progress', async () => {
const invocation = shellTool.build({ command: 'cat img' });
const invocation = shellTool.build({
command: 'cat img',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
mockShellOutputCallback({ type: 'binary_detected' });
@@ -370,7 +487,7 @@ describe('ShellTool', () => {
describe('addCoAuthorToGitCommit', () => {
it('should add co-author to git commit with double quotes', async () => {
const command = 'git commit -m "Initial commit"';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
// Mock the shell execution to return success
@@ -401,7 +518,7 @@ describe('ShellTool', () => {
it('should add co-author to git commit with single quotes', async () => {
const command = "git commit -m 'Fix bug'";
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -430,7 +547,7 @@ describe('ShellTool', () => {
it('should handle git commit with additional flags', async () => {
const command = 'git commit -a -m "Add feature"';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -459,7 +576,7 @@ describe('ShellTool', () => {
it('should not modify non-git commands', async () => {
const command = 'npm install';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -487,7 +604,7 @@ describe('ShellTool', () => {
it('should not modify git commands without -m flag', async () => {
const command = 'git commit';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -515,7 +632,7 @@ describe('ShellTool', () => {
it('should handle git commit with escaped quotes in message', async () => {
const command = 'git commit -m "Fix \\"quoted\\" text"';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -551,7 +668,7 @@ describe('ShellTool', () => {
});
const command = 'git commit -m "Initial commit"';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -586,7 +703,7 @@ describe('ShellTool', () => {
});
const command = 'git commit -m "Test commit"';
const invocation = shellTool.build({ command });
const invocation = shellTool.build({ command, is_background: false });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
@@ -617,7 +734,7 @@ describe('ShellTool', () => {
describe('shouldConfirmExecute', () => {
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
const params = { command: 'npm install' };
const params = { command: 'npm install', is_background: false };
const invocation = shellTool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
@@ -632,7 +749,10 @@ describe('ShellTool', () => {
);
// Should now be whitelisted
const secondInvocation = shellTool.build({ command: 'npm test' });
const secondInvocation = shellTool.build({
command: 'npm test',
is_background: false,
});
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
new AbortController().signal,
);
@@ -640,7 +760,9 @@ describe('ShellTool', () => {
});
it('should throw an error if validation fails', () => {
expect(() => shellTool.build({ command: '' })).toThrow();
expect(() =>
shellTool.build({ command: '', is_background: false }),
).toThrow();
});
});
});
@@ -658,6 +780,7 @@ describe('validateToolParams', () => {
const result = shellTool.validateToolParams({
command: 'ls',
directory: 'test',
is_background: false,
});
expect(result).toBeNull();
});
@@ -674,6 +797,7 @@ describe('validateToolParams', () => {
const result = shellTool.validateToolParams({
command: 'ls',
directory: 'test2',
is_background: false,
});
expect(result).toContain('is not a registered workspace directory');
});
@@ -692,6 +816,7 @@ describe('build', () => {
const invocation = shellTool.build({
command: 'ls',
directory: 'test',
is_background: false,
});
expect(invocation).toBeDefined();
});
@@ -709,6 +834,7 @@ describe('build', () => {
shellTool.build({
command: 'ls',
directory: 'test2',
is_background: false,
}),
).toThrow('is not a registered workspace directory');
});