Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/tools/BashTool/BashTool.errorOutput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,61 @@ describe('BashTool error output (#1231)', () => {
expect(formatError(err)).toBe('Exit code 1')
})

test('query-timeout abort returns cancellation metadata and specific message', async () => {
const ctx = makeCtx() as {
abortController: AbortController
}

setTimeout(() => ctx.abortController.abort('query-timeout'), 50).unref()

const response = await BashTool.call(
{ command: 'sleep 5', description: 'wait' } as never,
ctx as never,
)

expect(response.data?.interrupted).toBe(true)
expect(response.data?.isAbort).toBe(true)
expect(response.data?.abortReason).toBe('query-timeout')
expect(response.data?.abortMessage).toBe(
'Command was interrupted because the query hit its timeout.',
)

const toolResult = BashTool.mapToolResultToToolResultBlockParam(
response.data!,
'toolu_timeout',
)
expect(toolResult.is_error).toBe(true)
expect(String(toolResult.content)).toContain(
'Command was interrupted because the query hit its timeout.',
)
})

test('user-cancel abort returns cancellation metadata without treating exit 1 as abort', async () => {
const ctx = makeCtx() as {
abortController: AbortController
}

setTimeout(() => ctx.abortController.abort('user-cancel'), 50).unref()

const response = await BashTool.call(
{ command: 'sleep 5', description: 'wait' } as never,
ctx as never,
)

expect(response.data?.interrupted).toBe(true)
expect(response.data?.isAbort).toBe(true)
expect(response.data?.abortReason).toBe('user-abort')
expect(response.data?.abortMessage).toBe(
'Command was interrupted because the enclosing query was aborted.',
)

// Negative case: a separate non-aborted command failure must remain an
// ordinary ShellError without inheriting abort metadata from this test.
const err = await expectShellError('exit 1')
expect(err.interrupted).toBe(false)
expect(err.abortReason).toBeUndefined()
})

// Regression for #1359 — when the captured output rolls to a file because
// it exceeds getMaxOutputLength (default 30k bytes) AND the command exits
// non-zero, the model used to see only the truncated first chunk on
Expand Down
28 changes: 23 additions & 5 deletions src/tools/BashTool/BashTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ const outputSchema = lazySchema(() => z.object({
stderr: z.string().describe('The standard error output of the command'),
rawOutputPath: z.string().optional().describe('Path to raw output file for large MCP tool outputs'),
interrupted: z.boolean().describe('Whether the command was interrupted'),
isAbort: z.boolean().optional().describe('Whether the command was cancelled through the abort path'),
abortReason: z.string().optional().describe('Normalized abort reason when the command was cancelled'),
abortMessage: z.string().optional().describe('Safe user-facing abort explanation'),
isImage: z.boolean().optional().describe('Flag to indicate if stdout contains image data'),
backgroundTaskId: z.string().optional().describe('ID of the background task if command is running in background'),
backgroundedByUser: z.boolean().optional().describe('True if the user manually backgrounded the command with Ctrl+B'),
Expand Down Expand Up @@ -662,6 +665,7 @@ export const BashTool = buildTool({
backgroundTaskId,
backgroundedByUser,
assistantAutoBackgrounded,
abortMessage,
structuredContent,
persistedOutputPath,
persistedOutputSize,
Expand Down Expand Up @@ -707,7 +711,7 @@ export const BashTool = buildTool({
let errorMessage = normalizedStderr.trim();
if (interrupted) {
if (normalizedStderr) errorMessage += EOL;
errorMessage += '<error>Command was aborted before completion</error>';
errorMessage += `<error>${abortMessage ?? 'Command was aborted before completion'}</error>`;
}
let backgroundInfo = '';
if (backgroundTaskId) {
Expand Down Expand Up @@ -796,7 +800,7 @@ export const BashTool = buildTool({
// Get the final result from the generator's return value
result = generatorResult.value;
trackGitOperations(input.command, result.code, result.stdout);
const isInterrupt = result.interrupted && abortController.signal.reason === 'interrupt';
const isAbort = result.isAbort === true;

// stderr is interleaved in stdout (merged fd) — result.stdout has both
stdoutAccumulator.append((result.stdout || '').trimEnd() + EOL);
Expand Down Expand Up @@ -842,7 +846,7 @@ export const BashTool = buildTool({
if (result.preSpawnError) {
throw new Error(result.preSpawnError);
}
if (interpretationResult.isError && !isInterrupt) {
if (interpretationResult.isError && !isAbort) {
// Merged-fd setup puts both streams into result.stdout. Carry it on
// the stdout slot of ShellError (matches PowerShellTool.tsx:595) so
// getErrorParts() emits the captured output alongside "Exit code N"
Expand Down Expand Up @@ -872,7 +876,11 @@ export const BashTool = buildTool({
)
}
}
throw new ShellError(errorStdout, result.stderr || '', result.code, result.interrupted);
throw new ShellError(errorStdout, result.stderr || '', result.code, result.interrupted, {
abortReason: result.abortReason,
abortMessage: result.abortMessage,
isAbort: result.isAbort
});
}
wasInterrupted = result.interrupted;
} finally {
Expand Down Expand Up @@ -906,7 +914,14 @@ export const BashTool = buildTool({
stdout_length: stdout.length,
stderr_length: 0,
exit_code: result.code,
interrupted: wasInterrupted
interrupted: wasInterrupted,
duration_ms: result.durationMs,
signal: result.signal as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined,
signal_aborted: result.signalAborted,
is_abort: result.isAbort,
abort_reason: result.abortReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined,
result_stdout_length: result.stdoutLength,
result_stderr_length: result.stderrLength
});

// Log code indexing tool usage
Expand Down Expand Up @@ -953,6 +968,9 @@ export const BashTool = buildTool({
stdout: compressedStdout,
stderr: stderrForShellReset,
interrupted: wasInterrupted,
isAbort: result.isAbort,
abortReason: result.abortReason,
abortMessage: result.abortMessage,
isImage,
returnCodeInterpretation: interpretationResult?.message,
noOutputExpected: isSilentBashCommand(input.command),
Expand Down
81 changes: 81 additions & 0 deletions src/tools/PowerShellTool/PowerShellTool.errorOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from 'bun:test'
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
appendPersistedPowerShellOutputHint,
MAX_PERSISTED_POWERSHELL_OUTPUT_SIZE,
persistPowerShellOutputFile,
} from './PowerShellTool.js'

describe('PowerShellTool persisted error output', () => {
test('hint reports a cap instead of "full output" when the roll file was truncated', () => {
const original = MAX_PERSISTED_POWERSHELL_OUTPUT_SIZE + 4096
const hint = appendPersistedPowerShellOutputHint('preview', '/tmp/out', original, true)

expect(hint).not.toContain('full output')
expect(hint).toContain('capped')
expect(hint).toContain(`first ${MAX_PERSISTED_POWERSHELL_OUTPUT_SIZE} bytes`)
expect(hint).toContain(`${original}-byte`)
expect(hint).toContain('/tmp/out')
})

test('hint keeps "full output" wording when the roll file fit under the cap', () => {
const hint = appendPersistedPowerShellOutputHint('preview', '/tmp/out', 1234, false)
expect(hint).toMatch(/full output \(1234 bytes\) saved to \/tmp\/out; read with the Read tool/)
})

test('caps the destination copy and leaves the rolled-output source intact', async () => {
const dir = mkdtempSync(join(tmpdir(), 'powershell-persist-source-'))
const source = join(dir, 'roll.txt')
const body = 'x'.repeat(4096)
writeFileSync(source, body)
const cap = 1024
let dest: string | undefined

try {
const persisted = await persistPowerShellOutputFile(source, 'powershell-persist-src-test', cap)
expect(persisted).not.toBeNull()
dest = persisted!.path
expect(persisted!.size).toBe(4096)
expect(persisted!.truncated).toBe(true)
expect(statSync(source).size).toBe(4096)
expect(readFileSync(source, 'utf8')).toBe(body)
expect(statSync(dest).size).toBe(cap)
} finally {
if (dest && existsSync(dest)) rmSync(dest, { force: true })
rmSync(dir, { recursive: true, force: true })
}
})

test('capped destination contains exactly the first maxSize bytes', async () => {
const dir = mkdtempSync(join(tmpdir(), 'powershell-persist-head-'))
const source = join(dir, 'roll.txt')
const cap = 2048
const head = 'A'.repeat(cap)
const tail = 'B'.repeat(cap)
writeFileSync(source, head + tail)
let dest: string | undefined

try {
const persisted = await persistPowerShellOutputFile(source, 'powershell-persist-head-test', cap)
expect(persisted).not.toBeNull()
dest = persisted!.path
expect(persisted!.truncated).toBe(true)
const saved = readFileSync(dest, 'utf8')
expect(saved.length).toBe(cap)
expect(saved).toBe(head)
expect(saved).not.toContain('B')
} finally {
if (dest && existsSync(dest)) rmSync(dest, { force: true })
rmSync(dir, { recursive: true, force: true })
}
})
})
Loading