diff options
| author | Abhi <[email protected]> | 2025-07-25 21:56:49 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-26 01:56:49 +0000 |
| commit | ca5dd28ab60d78f42150460cbe9d4ed58d40afe4 (patch) | |
| tree | 6f3b3b373133a6c0bf8bb9cee8a10cc06ac83c9a /packages/core/src/tools | |
| parent | ad2ef080aae2e21bf04ad8e922719ceaa81f1e5f (diff) | |
refactor(core): Centralize shell logic into ShellExecutionService (#4823)
Diffstat (limited to 'packages/core/src/tools')
| -rw-r--r-- | packages/core/src/tools/shell.test.ts | 476 | ||||
| -rw-r--r-- | packages/core/src/tools/shell.ts | 347 |
2 files changed, 506 insertions, 317 deletions
diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 460c871a..55364197 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -4,164 +4,384 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { expect, describe, it, vi, beforeEach } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; + +const mockShellExecutionService = vi.hoisted(() => vi.fn()); +vi.mock('../services/shellExecutionService.js', () => ({ + ShellExecutionService: { execute: mockShellExecutionService }, +})); +vi.mock('fs'); +vi.mock('os'); +vi.mock('crypto'); +vi.mock('../utils/summarizer.js'); + +import { isCommandAllowed } from '../utils/shell-utils.js'; import { ShellTool } from './shell.js'; -import { Config } from '../config/config.js'; +import { type Config } from '../config/config.js'; +import { + type ShellExecutionResult, + type ShellOutputEvent, +} from '../services/shellExecutionService.js'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; import * as summarizer from '../utils/summarizer.js'; -import { GeminiClient } from '../core/client.js'; -import { ToolExecuteConfirmationDetails } from './tools.js'; -import os from 'os'; +import { ToolConfirmationOutcome } from './tools.js'; +import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; -describe('ShellTool Bug Reproduction', () => { +describe('ShellTool', () => { let shellTool: ShellTool; - let config: Config; + let mockConfig: Config; + let mockShellOutputCallback: (event: ShellOutputEvent) => void; + let resolveExecutionPromise: (result: ShellExecutionResult) => void; beforeEach(() => { - config = { - getCoreTools: () => undefined, - getExcludeTools: () => undefined, - getDebugMode: () => false, - getGeminiClient: () => ({}) as GeminiClient, - getTargetDir: () => '.', - getSummarizeToolOutputConfig: () => ({ - [shellTool.name]: {}, - }), + vi.clearAllMocks(); + + mockConfig = { + getCoreTools: vi.fn().mockReturnValue([]), + getExcludeTools: vi.fn().mockReturnValue([]), + getDebugMode: vi.fn().mockReturnValue(false), + getTargetDir: vi.fn().mockReturnValue('/test/dir'), + getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), + getGeminiClient: vi.fn(), } as unknown as Config; - shellTool = new ShellTool(config); - }); - it('should not let the summarizer override the return display', async () => { - const summarizeSpy = vi - .spyOn(summarizer, 'summarizeToolOutput') - .mockResolvedValue('summarized output'); + shellTool = new ShellTool(mockConfig); - const abortSignal = new AbortController().signal; - const result = await shellTool.execute( - { command: 'echo hello' }, - abortSignal, - () => {}, + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( + Buffer.from('abcdef', 'hex'), ); - expect(result.returnDisplay).toBe('hello' + os.EOL); - expect(result.llmContent).toBe('summarized output'); - expect(summarizeSpy).toHaveBeenCalled(); + // Capture the output callback to simulate streaming events from the service + mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { + mockShellOutputCallback = callback; + return { + pid: 12345, + result: new Promise((resolve) => { + resolveExecutionPromise = resolve; + }), + }; + }); }); - it('should not call summarizer if disabled in config', async () => { - config = { - getCoreTools: () => undefined, - getExcludeTools: () => undefined, - getDebugMode: () => false, - getGeminiClient: () => ({}) as GeminiClient, - getTargetDir: () => '.', - getSummarizeToolOutputConfig: () => ({}), - } as unknown as Config; - shellTool = new ShellTool(config); + describe('isCommandAllowed', () => { + it('should allow a command if no restrictions are provided', () => { + (mockConfig.getCoreTools as Mock).mockReturnValue(undefined); + (mockConfig.getExcludeTools as Mock).mockReturnValue(undefined); + expect(isCommandAllowed('ls -l', mockConfig).allowed).toBe(true); + }); - const summarizeSpy = vi - .spyOn(summarizer, 'summarizeToolOutput') - .mockResolvedValue('summarized output'); + it('should block a command with command substitution using $()', () => { + expect(isCommandAllowed('echo $(rm -rf /)', mockConfig).allowed).toBe( + false, + ); + }); + }); - const abortSignal = new AbortController().signal; - const result = await shellTool.execute( - { command: 'echo hello' }, - abortSignal, - () => {}, - ); + describe('validateToolParams', () => { + it('should return null for a valid command', () => { + expect(shellTool.validateToolParams({ command: 'ls -l' })).toBeNull(); + }); + + it('should return an error for an empty command', () => { + expect(shellTool.validateToolParams({ command: ' ' })).toBe( + 'Command cannot be empty.', + ); + }); - expect(result.returnDisplay).toBe('hello' + os.EOL); - expect(result.llmContent).not.toBe('summarized output'); - expect(summarizeSpy).not.toHaveBeenCalled(); + it('should return an error for a non-existent directory', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + expect( + shellTool.validateToolParams({ command: 'ls', directory: 'rel/path' }), + ).toBe('Directory must exist.'); + }); }); - it('should pass token budget to summarizer', async () => { - config = { - getCoreTools: () => undefined, - getExcludeTools: () => undefined, - getDebugMode: () => false, - getGeminiClient: () => ({}) as GeminiClient, - getTargetDir: () => '.', - getSummarizeToolOutputConfig: () => ({ + describe('execute', () => { + const mockAbortSignal = new AbortController().signal; + + const resolveShellExecution = ( + result: Partial<ShellExecutionResult> = {}, + ) => { + const fullResult: ShellExecutionResult = { + rawOutput: Buffer.from(result.output || ''), + output: 'Success', + stdout: 'Success', + stderr: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + ...result, + }; + resolveExecutionPromise(fullResult); + }; + + it('should wrap command on linux and parse pgrep output', async () => { + const promise = shellTool.execute( + { command: 'my-command &' }, + mockAbortSignal, + ); + resolveShellExecution({ pid: 54321 }); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); // Service PID and background PID + + const result = await promise; + + const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); + const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; + expect(mockShellExecutionService).toHaveBeenCalledWith( + wrappedCommand, + expect.any(String), + expect.any(Function), + mockAbortSignal, + ); + expect(result.llmContent).toContain('Background PIDs: 54322'); + expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); + }); + + it('should not wrap command on windows', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const promise = shellTool.execute({ command: 'dir' }, mockAbortSignal); + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + stdout: '', + stderr: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + }); + await promise; + expect(mockShellExecutionService).toHaveBeenCalledWith( + 'dir', + expect.any(String), + expect.any(Function), + mockAbortSignal, + ); + }); + + it('should format error messages correctly', async () => { + const error = new Error('wrapped command failed'); + const promise = shellTool.execute( + { command: 'user-command' }, + mockAbortSignal, + ); + resolveShellExecution({ + error, + exitCode: 1, + output: 'err', + stderr: 'err', + rawOutput: Buffer.from('err'), + stdout: '', + signal: null, + aborted: false, + pid: 12345, + }); + + const result = await promise; + // The final llmContent should contain the user's command, not the wrapper + expect(result.llmContent).toContain('Error: wrapped command failed'); + expect(result.llmContent).not.toContain('pgrep'); + }); + + it('should summarize output when configured', async () => { + (mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({ [shellTool.name]: { tokenBudget: 1000 }, - }), - } as unknown as Config; - shellTool = new ShellTool(config); + }); + vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue( + 'summarized output', + ); - const summarizeSpy = vi - .spyOn(summarizer, 'summarizeToolOutput') - .mockResolvedValue('summarized output'); + const promise = shellTool.execute({ command: 'ls' }, mockAbortSignal); + resolveExecutionPromise({ + output: 'long output', + rawOutput: Buffer.from('long output'), + stdout: 'long output', + stderr: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + }); - const abortSignal = new AbortController().signal; - await shellTool.execute({ command: 'echo "hello"' }, abortSignal, () => {}); + const result = await promise; - expect(summarizeSpy).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.any(Object), - 1000, - ); - }); + expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( + expect.any(String), + mockConfig.getGeminiClient(), + mockAbortSignal, + 1000, + ); + expect(result.llmContent).toBe('summarized output'); + expect(result.returnDisplay).toBe('long output'); + }); - it('should use default token budget if not specified', async () => { - config = { - getCoreTools: () => undefined, - getExcludeTools: () => undefined, - getDebugMode: () => false, - getGeminiClient: () => ({}) as GeminiClient, - getTargetDir: () => '.', - getSummarizeToolOutputConfig: () => ({ - [shellTool.name]: {}, - }), - } as unknown as Config; - shellTool = new ShellTool(config); + it('should clean up the temp file on synchronous execution error', async () => { + const error = new Error('sync spawn error'); + mockShellExecutionService.mockImplementation(() => { + throw error; + }); + vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists - const summarizeSpy = vi - .spyOn(summarizer, 'summarizeToolOutput') - .mockResolvedValue('summarized output'); + await expect( + shellTool.execute({ command: 'a-command' }, mockAbortSignal), + ).rejects.toThrow(error); - const abortSignal = new AbortController().signal; - await shellTool.execute({ command: 'echo "hello"' }, abortSignal, () => {}); + const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); + expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); + }); - expect(summarizeSpy).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.any(Object), - undefined, - ); - }); + describe('Streaming to `updateOutput`', () => { + let updateOutputMock: Mock; + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + updateOutputMock = vi.fn(); + }); + afterEach(() => { + vi.useRealTimers(); + }); - it('should pass GEMINI_CLI environment variable to executed commands', async () => { - config = { - getCoreTools: () => undefined, - getExcludeTools: () => undefined, - getDebugMode: () => false, - getGeminiClient: () => ({}) as GeminiClient, - getTargetDir: () => '.', - getSummarizeToolOutputConfig: () => ({}), - } as unknown as Config; - shellTool = new ShellTool(config); + it('should throttle text output updates', async () => { + const promise = shellTool.execute( + { command: 'stream' }, + mockAbortSignal, + updateOutputMock, + ); + + // First chunk, should be throttled. + mockShellOutputCallback({ + type: 'data', + stream: 'stdout', + chunk: 'hello ', + }); + expect(updateOutputMock).not.toHaveBeenCalled(); + + // Advance time past the throttle interval. + await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); + + // Send a second chunk. THIS event triggers the update with the CUMULATIVE content. + mockShellOutputCallback({ + type: 'data', + stream: 'stderr', + chunk: 'world', + }); + + // It should have been called once now with the combined output. + expect(updateOutputMock).toHaveBeenCalledOnce(); + expect(updateOutputMock).toHaveBeenCalledWith('hello \nworld'); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + stdout: '', + stderr: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + }); + await promise; + }); + + it('should immediately show binary detection message and throttle progress', async () => { + const promise = shellTool.execute( + { command: 'cat img' }, + mockAbortSignal, + updateOutputMock, + ); - const abortSignal = new AbortController().signal; - const command = - os.platform() === 'win32' ? 'echo %GEMINI_CLI%' : 'echo "$GEMINI_CLI"'; - const result = await shellTool.execute({ command }, abortSignal, () => {}); + mockShellOutputCallback({ type: 'binary_detected' }); + expect(updateOutputMock).toHaveBeenCalledOnce(); + expect(updateOutputMock).toHaveBeenCalledWith( + '[Binary output detected. Halting stream...]', + ); - expect(result.returnDisplay).toBe('1' + os.EOL); + mockShellOutputCallback({ + type: 'binary_progress', + bytesReceived: 1024, + }); + expect(updateOutputMock).toHaveBeenCalledOnce(); + + // Advance time past the throttle interval. + await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); + + // Send a SECOND progress event. This one will trigger the flush. + mockShellOutputCallback({ + type: 'binary_progress', + bytesReceived: 2048, + }); + + // Now it should be called a second time with the latest progress. + expect(updateOutputMock).toHaveBeenCalledTimes(2); + expect(updateOutputMock).toHaveBeenLastCalledWith( + '[Receiving binary output... 2.0 KB received]', + ); + + resolveExecutionPromise({ + rawOutput: Buffer.from(''), + output: '', + stdout: '', + stderr: '', + exitCode: 0, + signal: null, + error: null, + aborted: false, + pid: 12345, + }); + await promise; + }); + }); }); -}); -describe('shouldConfirmExecute', () => { - it('should de-duplicate command roots before asking for confirmation', async () => { - const shellTool = new ShellTool({ - getCoreTools: () => ['run_shell_command'], - getExcludeTools: () => [], - } as unknown as Config); - const result = (await shellTool.shouldConfirmExecute( - { - command: 'git status && git log', - }, - new AbortController().signal, - )) as ToolExecuteConfirmationDetails; - expect(result.rootCommand).toEqual('git'); + describe('shouldConfirmExecute', () => { + it('should request confirmation for a new command and whitelist it on "Always"', async () => { + const params = { command: 'npm install' }; + const confirmation = await shellTool.shouldConfirmExecute( + params, + new AbortController().signal, + ); + + expect(confirmation).not.toBe(false); + expect(confirmation && confirmation.type).toBe('exec'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (confirmation as any).onConfirm( + ToolConfirmationOutcome.ProceedAlways, + ); + + // Should now be whitelisted + const secondConfirmation = await shellTool.shouldConfirmExecute( + { command: 'npm test' }, + new AbortController().signal, + ); + expect(secondConfirmation).toBe(false); + }); + + it('should skip confirmation if validation fails', async () => { + const confirmation = await shellTool.shouldConfirmExecute( + { command: '' }, + new AbortController().signal, + ); + expect(confirmation).toBe(false); + }); }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index d90ae678..02fcbb7f 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -20,22 +20,25 @@ import { import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; -import stripAnsi from 'strip-ansi'; +import { summarizeToolOutput } from '../utils/summarizer.js'; +import { + ShellExecutionService, + ShellOutputEvent, +} from '../services/shellExecutionService.js'; +import { formatMemoryUsage } from '../utils/formatters.js'; import { getCommandRoots, isCommandAllowed, stripShellWrapper, } from '../utils/shell-utils.js'; +export const OUTPUT_UPDATE_INTERVAL_MS = 1000; + export interface ShellToolParams { command: string; description?: string; directory?: string; } -import { spawn } from 'child_process'; -import { summarizeToolOutput } from '../utils/summarizer.js'; - -const OUTPUT_UPDATE_INTERVAL_MS = 1000; export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { static Name: string = 'run_shell_command'; @@ -196,216 +199,182 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { .toString('hex')}.tmp`; const tempFilePath = path.join(os.tmpdir(), tempFileName); - // pgrep is not available on Windows, so we can't get background PIDs - const commandToExecute = isWindows - ? strippedCommand - : (() => { - // wrap command to append subprocess pids (via pgrep) to temporary file - let command = strippedCommand.trim(); - if (!command.endsWith('&')) command += ';'; - return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; - })(); - - // spawn command in specified directory (or project root if not specified) - const shell = isWindows - ? spawn('cmd.exe', ['/c', commandToExecute], { - stdio: ['ignore', 'pipe', 'pipe'], - // detached: true, // ensure subprocess starts its own process group (esp. in Linux) - cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), - env: { - ...process.env, - GEMINI_CLI: '1', - }, - }) - : spawn('bash', ['-c', commandToExecute], { - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, // ensure subprocess starts its own process group (esp. in Linux) - cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), - env: { - ...process.env, - GEMINI_CLI: '1', - }, - }); + try { + // pgrep is not available on Windows, so we can't get background PIDs + const commandToExecute = isWindows + ? strippedCommand + : (() => { + // wrap command to append subprocess pids (via pgrep) to temporary file + let command = strippedCommand.trim(); + if (!command.endsWith('&')) command += ';'; + return `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + })(); - let exited = false; - let stdout = ''; - let output = ''; - let lastUpdateTime = Date.now(); + const cwd = path.resolve( + this.config.getTargetDir(), + params.directory || '', + ); - const appendOutput = (str: string) => { - output += str; - if ( - updateOutput && - Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS - ) { - updateOutput(output); - lastUpdateTime = Date.now(); - } - }; + let cumulativeStdout = ''; + let cumulativeStderr = ''; - shell.stdout.on('data', (data: Buffer) => { - // continue to consume post-exit for background processes - // removing listeners can overflow OS buffer and block subprocesses - // destroying (e.g. shell.stdout.destroy()) can terminate subprocesses via SIGPIPE - if (!exited) { - const str = stripAnsi(data.toString()); - stdout += str; - appendOutput(str); - } - }); + let lastUpdateTime = Date.now(); + let isBinaryStream = false; - let stderr = ''; - shell.stderr.on('data', (data: Buffer) => { - if (!exited) { - const str = stripAnsi(data.toString()); - stderr += str; - appendOutput(str); - } - }); - - let error: Error | null = null; - shell.on('error', (err: Error) => { - error = err; - // remove wrapper from user's command in error message - error.message = error.message.replace(commandToExecute, params.command); - }); + const { result: resultPromise } = ShellExecutionService.execute( + commandToExecute, + cwd, + (event: ShellOutputEvent) => { + if (!updateOutput) { + return; + } - let code: number | null = null; - let processSignal: NodeJS.Signals | null = null; - const exitHandler = ( - _code: number | null, - _signal: NodeJS.Signals | null, - ) => { - exited = true; - code = _code; - processSignal = _signal; - }; - shell.on('exit', exitHandler); + let currentDisplayOutput = ''; + let shouldUpdate = false; - const abortHandler = async () => { - if (shell.pid && !exited) { - if (os.platform() === 'win32') { - // For Windows, use taskkill to kill the process tree - spawn('taskkill', ['/pid', shell.pid.toString(), '/f', '/t']); - } else { - try { - // attempt to SIGTERM process group (negative PID) - // fall back to SIGKILL (to group) after 200ms - process.kill(-shell.pid, 'SIGTERM'); - await new Promise((resolve) => setTimeout(resolve, 200)); - if (shell.pid && !exited) { - process.kill(-shell.pid, 'SIGKILL'); - } - } catch (_e) { - // if group kill fails, fall back to killing just the main process - try { - if (shell.pid) { - shell.kill('SIGKILL'); + switch (event.type) { + case 'data': + if (isBinaryStream) break; // Don't process text if we are in binary mode + if (event.stream === 'stdout') { + cumulativeStdout += event.chunk; + } else { + cumulativeStderr += event.chunk; + } + currentDisplayOutput = + cumulativeStdout + + (cumulativeStderr ? `\n${cumulativeStderr}` : ''); + if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { + shouldUpdate = true; + } + break; + case 'binary_detected': + isBinaryStream = true; + currentDisplayOutput = + '[Binary output detected. Halting stream...]'; + shouldUpdate = true; + break; + case 'binary_progress': + isBinaryStream = true; + currentDisplayOutput = `[Receiving binary output... ${formatMemoryUsage( + event.bytesReceived, + )} received]`; + if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { + shouldUpdate = true; } - } catch (_e) { - console.error(`failed to kill shell process ${shell.pid}: ${_e}`); + break; + default: { + throw new Error('An unhandled ShellOutputEvent was found.'); } } - } - } - }; - signal.addEventListener('abort', abortHandler); - // wait for the shell to exit - try { - await new Promise((resolve) => shell.on('exit', resolve)); - } finally { - signal.removeEventListener('abort', abortHandler); - } + if (shouldUpdate) { + updateOutput(currentDisplayOutput); + lastUpdateTime = Date.now(); + } + }, + signal, + ); - // parse pids (pgrep output) from temporary file and remove it - const backgroundPIDs: number[] = []; - if (os.platform() !== 'win32') { - if (fs.existsSync(tempFilePath)) { - const pgrepLines = fs - .readFileSync(tempFilePath, 'utf8') - .split('\n') - .filter(Boolean); - for (const line of pgrepLines) { - if (!/^\d+$/.test(line)) { - console.error(`pgrep: ${line}`); + const result = await resultPromise; + + const backgroundPIDs: number[] = []; + if (os.platform() !== 'win32') { + if (fs.existsSync(tempFilePath)) { + const pgrepLines = fs + .readFileSync(tempFilePath, 'utf8') + .split('\n') + .filter(Boolean); + for (const line of pgrepLines) { + if (!/^\d+$/.test(line)) { + console.error(`pgrep: ${line}`); + } + const pid = Number(line); + if (pid !== result.pid) { + backgroundPIDs.push(pid); + } } - const pid = Number(line); - // exclude the shell subprocess pid - if (pid !== shell.pid) { - backgroundPIDs.push(pid); + } else { + if (!signal.aborted) { + console.error('missing pgrep output'); } } - fs.unlinkSync(tempFilePath); - } else { - if (!signal.aborted) { - console.error('missing pgrep output'); - } } - } - let llmContent = ''; - if (signal.aborted) { - llmContent = 'Command was cancelled by user before it could complete.'; - if (output.trim()) { - llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`; + let llmContent = ''; + if (result.aborted) { + llmContent = 'Command was cancelled by user before it could complete.'; + if (result.output.trim()) { + llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${result.output}`; + } else { + llmContent += ' There was no output before it was cancelled.'; + } } else { - llmContent += ' There was no output before it was cancelled.'; + // Create a formatted error string for display, replacing the wrapper command + // with the user-facing command. + const finalError = result.error + ? result.error.message.replace(commandToExecute, params.command) + : '(none)'; + + llmContent = [ + `Command: ${params.command}`, + `Directory: ${params.directory || '(root)'}`, + `Stdout: ${result.stdout || '(empty)'}`, + `Stderr: ${result.stderr || '(empty)'}`, + `Error: ${finalError}`, // Use the cleaned error string. + `Exit Code: ${result.exitCode ?? '(none)'}`, + `Signal: ${result.signal ?? '(none)'}`, + `Background PIDs: ${ + backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)' + }`, + `Process Group PGID: ${result.pid ?? '(none)'}`, + ].join('\n'); } - } else { - llmContent = [ - `Command: ${params.command}`, - `Directory: ${params.directory || '(root)'}`, - `Stdout: ${stdout || '(empty)'}`, - `Stderr: ${stderr || '(empty)'}`, - `Error: ${error ?? '(none)'}`, - `Exit Code: ${code ?? '(none)'}`, - `Signal: ${processSignal ?? '(none)'}`, - `Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`, - `Process Group PGID: ${shell.pid ?? '(none)'}`, - ].join('\n'); - } - let returnDisplayMessage = ''; - if (this.config.getDebugMode()) { - returnDisplayMessage = llmContent; - } else { - if (output.trim()) { - returnDisplayMessage = output; + let returnDisplayMessage = ''; + if (this.config.getDebugMode()) { + returnDisplayMessage = llmContent; } else { - // Output is empty, let's provide a reason if the command failed or was cancelled - if (signal.aborted) { - returnDisplayMessage = 'Command cancelled by user.'; - } else if (processSignal) { - returnDisplayMessage = `Command terminated by signal: ${processSignal}`; - } else if (error) { - // If error is not null, it's an Error object (or other truthy value) - returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`; - } else if (code !== null && code !== 0) { - returnDisplayMessage = `Command exited with code: ${code}`; + if (result.output.trim()) { + returnDisplayMessage = result.output; + } else { + if (result.aborted) { + returnDisplayMessage = 'Command cancelled by user.'; + } else if (result.signal) { + returnDisplayMessage = `Command terminated by signal: ${result.signal}`; + } else if (result.error) { + returnDisplayMessage = `Command failed: ${getErrorMessage( + result.error, + )}`; + } else if (result.exitCode !== null && result.exitCode !== 0) { + returnDisplayMessage = `Command exited with code: ${result.exitCode}`; + } + // If output is empty and command succeeded (code 0, no error/signal/abort), + // returnDisplayMessage will remain empty, which is fine. } - // If output is empty and command succeeded (code 0, no error/signal/abort), - // returnDisplayMessage will remain empty, which is fine. } - } - const summarizeConfig = this.config.getSummarizeToolOutputConfig(); - if (summarizeConfig && summarizeConfig[this.name]) { - const summary = await summarizeToolOutput( - llmContent, - this.config.getGeminiClient(), - signal, - summarizeConfig[this.name].tokenBudget, - ); + const summarizeConfig = this.config.getSummarizeToolOutputConfig(); + if (summarizeConfig && summarizeConfig[this.name]) { + const summary = await summarizeToolOutput( + llmContent, + this.config.getGeminiClient(), + signal, + summarizeConfig[this.name].tokenBudget, + ); + return { + llmContent: summary, + returnDisplay: returnDisplayMessage, + }; + } + return { - llmContent: summary, + llmContent, returnDisplay: returnDisplayMessage, }; + } finally { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } } - - return { - llmContent, - returnDisplay: returnDisplayMessage, - }; } } |
