diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index d6470db29..747337a26 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -170,6 +170,42 @@ describe('ShellExecutionService', () => { expect(result.output).toBe(''); expect(onOutputEventMock).not.toHaveBeenCalled(); }); + + it('should truncate PTY output using a sliding window and show a warning', async () => { + const MAX_SIZE = 16 * 1024 * 1024; + const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5); + const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5); + const chunk3 = 'c'.repeat(20); + + const { result } = await simulateExecution( + 'large-output', + async (pty) => { + pty.onData.mock.calls[0][0](chunk1); + await new Promise((resolve) => setImmediate(resolve)); + pty.onData.mock.calls[0][0](chunk2); + await new Promise((resolve) => setImmediate(resolve)); + pty.onData.mock.calls[0][0](chunk3); + await new Promise((resolve) => setImmediate(resolve)); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }, + ); + + const truncationMessage = + '[LLXPRT_CODE_WARNING: Output truncated. The buffer is limited to 16MB.]'; + expect(result.output).toContain(truncationMessage); + + const outputWithoutMessage = result.output + .substring(0, result.output.indexOf(truncationMessage)) + .trimEnd(); + + expect(outputWithoutMessage.length).toBe(MAX_SIZE); + + const expectedStart = (chunk1 + chunk2 + chunk3).slice(-MAX_SIZE); + expect( + outputWithoutMessage.startsWith(expectedStart.substring(0, 10)), + ).toBe(true); + expect(outputWithoutMessage.endsWith('c'.repeat(20))).toBe(true); + }, 20000); }); describe('Failed Execution', () => { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 8c90649fd..d01302b70 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -25,16 +25,8 @@ function stripAnsiIfPresent(value: string): string { : value; } -// @ts-expect-error getFullText is not a public API. -const getFullText = (terminal: Terminal) => { - const buffer = terminal.buffer.active; - const lines: string[] = []; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - lines.push(line ? line.translateToString(true) : ''); - } - return lines.join('\n').trim(); -}; +// Note: getFullText was removed as the PTY path now uses truncatedOutput +// for bounded memory instead of extracting the full xterm terminal buffer. /** A structured result from a shell command execution. */ export interface ShellExecutionResult { @@ -429,6 +421,10 @@ export class ShellExecutionService { let sniffBuffer = Buffer.alloc(0); let totalBytesReceived = 0; + // Truncation tracking for PTY output (mirrors child_process path) + let truncatedOutput = ''; + let outputTruncated = false; + const handleOutput = (data: Buffer) => { processingChain = processingChain.then( () => @@ -464,10 +460,23 @@ export class ShellExecutionService { if (isStreamingRawContent) { const decodedChunk = decoder.decode(data, { stream: true }); + const strippedChunk = stripAnsiIfPresent(decodedChunk); + + // Apply truncation to maintain bounded memory + const { newBuffer, truncated } = this.appendAndTruncate( + truncatedOutput, + strippedChunk, + MAX_CHILD_PROCESS_BUFFER_SIZE, + ); + truncatedOutput = newBuffer; + if (truncated) { + outputTruncated = true; + } + headlessTerminal.write(decodedChunk, () => { onOutputEvent({ type: 'data', - chunk: stripAnsiIfPresent(decodedChunk), + chunk: strippedChunk, }); resolve(); }); @@ -495,11 +504,20 @@ export class ShellExecutionService { const finalize = () => { const finalBuffer = Buffer.concat(outputChunks); - const fullOutput = getFullText(headlessTerminal); + // Use our truncated output instead of the unbounded terminal buffer + let finalOutput = truncatedOutput; + if (outputTruncated) { + const truncationMessage = ` +[LLXPRT_CODE_WARNING: Output truncated. The buffer is limited to ${ + MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024) + }MB.]`; + finalOutput += truncationMessage; + } + resolve({ rawOutput: finalBuffer, - output: fullOutput, - stdout: fullOutput, // For PTY, stdout and stderr are combined + output: finalOutput.trim(), + stdout: truncatedOutput, // For PTY, stdout and stderr are combined stderr: '', // PTY combines output streams exitCode, signal: signal ?? null,