diff --git a/packages/builtin-tools/src/tools/BashTool/__tests__/commandSemantics.test.ts b/packages/builtin-tools/src/tools/BashTool/__tests__/commandSemantics.test.ts index baf014c13d..47d168d591 100644 --- a/packages/builtin-tools/src/tools/BashTool/__tests__/commandSemantics.test.ts +++ b/packages/builtin-tools/src/tools/BashTool/__tests__/commandSemantics.test.ts @@ -59,6 +59,34 @@ describe('interpretCommandResult', () => { expect(result.message).toBe('No matches found') }) + test('does not use an unexecuted rg command for && short-circuit semantics', () => { + const result = interpretCommandResult( + 'cd /definitely-missing && rg pattern .', + 1, + '', + 'cd: /definitely-missing: No such file or directory', + ) + expect(result.isError).toBe(true) + expect(result.message).toBe('Command failed with exit code 1') + }) + + test('does not use an unexecuted diff command for && short-circuit semantics', () => { + const result = interpretCommandResult( + "node -e 'process.exit(1)' && diff a.txt b.txt", + 1, + '', + '', + ) + expect(result.isError).toBe(true) + expect(result.message).toBe('Command failed with exit code 1') + }) + + test('keeps fallback semantics for a command reached through ||', () => { + const result = interpretCommandResult('false || rg pattern .', 1, '', '') + expect(result.isError).toBe(false) + expect(result.message).toBe('No matches found') + }) + // ─── rg (ripgrep) semantics ────────────────────────────────────── test('rg exit 1 means no matches (not error)', () => { const result = interpretCommandResult('rg pattern', 1, '', '') diff --git a/packages/builtin-tools/src/tools/BashTool/commandSemantics.ts b/packages/builtin-tools/src/tools/BashTool/commandSemantics.ts index 8be7630c2c..51c6905364 100644 --- a/packages/builtin-tools/src/tools/BashTool/commandSemantics.ts +++ b/packages/builtin-tools/src/tools/BashTool/commandSemantics.ts @@ -5,7 +5,10 @@ * For example, grep returns 1 when no matches are found, which is not an error condition. */ -import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js' +import { + splitCommand_DEPRECATED, + splitCommandWithOperators, +} from 'src/utils/bash/commands.js' export type CommandSemantic = ( exitCode: number, @@ -110,6 +113,17 @@ function extractBaseCommand(command: string): string { * May get it super wrong - don't depend on this for security */ function heuristicallyExtractBaseCommand(command: string): string { + // With an && list, the syntactically last command may never have run. For + // example, `cd /missing && rg needle .` exits 1 from `cd`, but applying rg's + // "1 = no matches" semantics would turn that real failure into success. + // The executor currently only exposes the aggregate exit code, so fall back + // to the conservative default whenever an AND-list makes the executed + // command ambiguous. Pipes remain safe: without pipefail their last command + // determines the aggregate status and is always executed. + if (splitCommandWithOperators(command).includes('&&')) { + return '' + } + const segments = splitCommand_DEPRECATED(command) // Take the last command as that's what determines the exit code