fix(chat): normalize bash output by stripping ANSI sequences and applying terminal control codes #2554
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { renderTerminalOutput } from './toolOutput';
|
||||||
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
|
||||||
import { tryParseJsonOutput } from '../toolRenderers';
|
import { tryParseJsonOutput } from '../toolRenderers';
|
||||||
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
import { getStreamingThrottleText } from '../../hooks/useStreamingTextThrottle';
|
||||||
@@ -7,18 +8,73 @@ import { getStreamingOutputAppend, getToolOutput } from './toolOutput';
|
|||||||
import { getToolDescriptionFallback } from './toolRenderUtils';
|
import { getToolDescriptionFallback } from './toolRenderUtils';
|
||||||
|
|
||||||
describe('getToolOutput', () => {
|
describe('getToolOutput', () => {
|
||||||
test('prefers authoritative state output', () => {
|
test('prefers state.output for completed tools', () => {
|
||||||
expect(getToolOutput('bash', 'final output', 'streamed output')).toBe('final output');
|
expect(getToolOutput('bash', 'final output', 'partial output', 'completed')).toBe('final output');
|
||||||
expect(getToolOutput('bash', '', 'streamed output')).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to streamed metadata output for bash', () => {
|
test('normalizes completed bash state output while preserving final-output precedence', () => {
|
||||||
expect(getToolOutput('bash', undefined, 'streamed output')).toBe('streamed output');
|
expect(getToolOutput('bash', '\u001B[32mFinal output\u001B[0m', 'partial output', 'completed')).toBe('Final output');
|
||||||
expect(getToolOutput('bash', undefined, '')).toBe(undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not expose metadata output for other tools', () => {
|
test('falls back to metadata.output for bash tools without state output', () => {
|
||||||
expect(getToolOutput('read', undefined, 'metadata output')).toBe(undefined);
|
expect(getToolOutput('bash', undefined, 'partial output', 'completed')).toBe('partial output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes bash metadata output for completed state', () => {
|
||||||
|
expect(getToolOutput('bash', undefined, 'Progress 10%\r\u001B[2KProgress 90%', 'completed')).toBe('Progress 90%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not normalize bash output while running', () => {
|
||||||
|
expect(getToolOutput('bash', '\u001B[32mRunning\u001B[0m', undefined, 'running')).toBe('\u001B[32mRunning\u001B[0m');
|
||||||
|
expect(getToolOutput('bash', undefined, 'Progress\r\u001B[2K', 'running')).toBe('Progress\r\u001B[2K');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores metadata.output for non-bash tools', () => {
|
||||||
|
expect(getToolOutput('read', undefined, 'partial output', 'completed')).toBe(undefined);
|
||||||
|
expect(getToolOutput('read', 'final output', 'partial output', 'completed')).toBe('final output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when bash has no output', () => {
|
||||||
|
expect(getToolOutput('bash', undefined, undefined, 'completed')).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores empty metadata.output for bash', () => {
|
||||||
|
expect(getToolOutput('bash', undefined, '', 'completed')).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderTerminalOutput', () => {
|
||||||
|
test('renders carriage-return progress updates as their latest value', () => {
|
||||||
|
expect(renderTerminalOutput('Downloading 10%\r\u001B[2KDownloading 90%')).toBe('Downloading 90%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes ANSI styles while preserving the output text', () => {
|
||||||
|
expect(renderTerminalOutput('\u001B[32mComplete\u001B[0m\n')).toBe('Complete\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies cursor-up progress updates to the prior line', () => {
|
||||||
|
expect(renderTerminalOutput('First\nWorking\u001B[1A\r\u001B[2KDone\n')).toBe('Done\nWorking');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSI K erases from cursor to end of line', () => {
|
||||||
|
expect(renderTerminalOutput('Hello World\u001B[5G\u001B[K')).toBe('Hell');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSI 1 K erases from beginning of line through cursor, preserving suffix', () => {
|
||||||
|
expect(renderTerminalOutput('Hello World\u001B[6G\u001B[1K')).toBe(' World');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSI 2 K erases entire line', () => {
|
||||||
|
expect(renderTerminalOutput('Hello World\u001B[2K')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles large single-line output without quadratic slowdown', () => {
|
||||||
|
const largeLine = 'A'.repeat(50000) + '\u001B[0m';
|
||||||
|
const start = performance.now();
|
||||||
|
const result = renderTerminalOutput(largeLine);
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
expect(result).toBe('A'.repeat(50000));
|
||||||
|
expect(elapsed).toBeLessThan(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1362,7 +1362,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
const input = stateWithData.input;
|
const input = stateWithData.input;
|
||||||
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output);
|
const rawOutput = getToolOutput(part.tool, stateWithData.output, metadata?.output, state.status);
|
||||||
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
const hasStringOutput = typeof rawOutput === 'string' && rawOutput.length > 0;
|
||||||
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
|
const rawOutputString = typeof rawOutput === 'string' ? rawOutput : '';
|
||||||
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
|
const isStreamingBash = part.tool === 'bash' && state.status === 'running';
|
||||||
|
|||||||
@@ -1,14 +1,133 @@
|
|||||||
|
const ensureLine = (lines: string[][], row: number): void => {
|
||||||
|
while (lines.length <= row) {
|
||||||
|
lines.push([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeTerminalCharacter = (lines: string[][], row: number, column: number, character: string): void => {
|
||||||
|
ensureLine(lines, row);
|
||||||
|
const line = lines[row];
|
||||||
|
while (line.length < column) {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
line[column] = character;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderTerminalOutput = (output: string): string => {
|
||||||
|
if (!output.includes('\u001B') && !output.includes('\r') && !output.includes('\b')) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[][] = [[]];
|
||||||
|
let row = 0;
|
||||||
|
let column = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < output.length; index += 1) {
|
||||||
|
const character = output[index];
|
||||||
|
|
||||||
|
if (character === '\n') {
|
||||||
|
row += 1;
|
||||||
|
column = 0;
|
||||||
|
ensureLine(lines, row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '\r') {
|
||||||
|
column = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '\b') {
|
||||||
|
column = Math.max(0, column - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character !== '\u001B') {
|
||||||
|
writeTerminalCharacter(lines, row, column, character);
|
||||||
|
column += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCharacter = output[index + 1];
|
||||||
|
if (nextCharacter === '[') {
|
||||||
|
const sequenceStart = index + 2;
|
||||||
|
let sequenceEnd = sequenceStart;
|
||||||
|
while (sequenceEnd < output.length && !/[\x40-\x7E]/.test(output[sequenceEnd])) {
|
||||||
|
sequenceEnd += 1;
|
||||||
|
}
|
||||||
|
if (sequenceEnd === output.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = output[sequenceEnd];
|
||||||
|
const parameters = output.slice(sequenceStart, sequenceEnd).split(';').map((value) => Number.parseInt(value, 10) || 0);
|
||||||
|
const count = parameters[0] || 1;
|
||||||
|
if (command === 'A') {
|
||||||
|
row = Math.max(0, row - count);
|
||||||
|
} else if (command === 'B') {
|
||||||
|
row += count;
|
||||||
|
ensureLine(lines, row);
|
||||||
|
} else if (command === 'C') {
|
||||||
|
column += count;
|
||||||
|
} else if (command === 'D') {
|
||||||
|
column = Math.max(0, column - count);
|
||||||
|
} else if (command === 'G') {
|
||||||
|
column = Math.max(0, count - 1);
|
||||||
|
} else if (command === 'H' || command === 'f') {
|
||||||
|
row = Math.max(0, (parameters[0] || 1) - 1);
|
||||||
|
column = Math.max(0, (parameters[1] || 1) - 1);
|
||||||
|
ensureLine(lines, row);
|
||||||
|
} else if (command === 'K') {
|
||||||
|
ensureLine(lines, row);
|
||||||
|
const line = lines[row];
|
||||||
|
const mode = parameters[0];
|
||||||
|
if (mode === 1) {
|
||||||
|
for (let i = 0; i <= column && i < line.length; i += 1) {
|
||||||
|
line[i] = ' ';
|
||||||
|
}
|
||||||
|
} else if (mode === 2) {
|
||||||
|
lines[row] = [];
|
||||||
|
} else {
|
||||||
|
line.length = Math.min(line.length, column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = sequenceEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCharacter === ']') {
|
||||||
|
const terminator = output.indexOf('\u0007', index + 2);
|
||||||
|
const stringTerminator = output.indexOf('\u001B\\', index + 2);
|
||||||
|
const end = terminator === -1
|
||||||
|
? stringTerminator
|
||||||
|
: stringTerminator === -1
|
||||||
|
? terminator
|
||||||
|
: Math.min(terminator, stringTerminator);
|
||||||
|
if (end === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index = output[end] === '\u0007' ? end : end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.map((line) => line.join('')).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
export const getToolOutput = (
|
export const getToolOutput = (
|
||||||
tool: string,
|
tool: string,
|
||||||
stateOutput: unknown,
|
stateOutput: unknown,
|
||||||
metadataOutput: unknown,
|
metadataOutput: unknown,
|
||||||
|
status?: string,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
|
const isBash = tool === 'bash';
|
||||||
|
const shouldNormalize = isBash && status !== 'running';
|
||||||
|
|
||||||
if (typeof stateOutput === 'string') {
|
if (typeof stateOutput === 'string') {
|
||||||
return stateOutput;
|
return shouldNormalize ? renderTerminalOutput(stateOutput) : stateOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tool === 'bash' && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
if (isBash && typeof metadataOutput === 'string' && metadataOutput.length > 0) {
|
||||||
return metadataOutput;
|
return shouldNormalize ? renderTerminalOutput(metadataOutput) : metadataOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user