Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit d4680cd

Browse files
committed
feat: update OutputInterceptor to use 50/50 head/tail split like Codex
- Replace single buffer with separate headBuffer and tailBuffer - Each buffer gets 50% of the preview budget - Head captures beginning of output, tail keeps rolling end - Middle content is dropped when output exceeds threshold - Preview shows: head + [omission indicator] + tail - Add tests for head/tail split behavior This approach ensures the LLM sees both: - The beginning (command startup, environment info, early errors) - The end (final results, exit codes, error summaries)
1 parent 21d2535 commit d4680cd

2 files changed

Lines changed: 304 additions & 37 deletions

File tree

src/integrations/terminal/OutputInterceptor.ts

Lines changed: 190 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ export interface OutputInterceptorOptions {
2626
* files, with only a preview shown to the LLM. The LLM can then use the `read_command_output`
2727
* tool to retrieve full contents or search through the output.
2828
*
29-
* The interceptor operates in two modes:
30-
* 1. **Buffer mode**: Output is accumulated in memory until it exceeds the preview threshold
31-
* 2. **Spill mode**: Once threshold is exceeded, output is streamed directly to disk
29+
* The interceptor uses a **head/tail buffer** strategy (inspired by Codex):
30+
* - 50% of the preview budget is allocated to the "head" (beginning of output)
31+
* - 50% of the preview budget is allocated to the "tail" (end of output)
32+
* - Middle content is dropped when output exceeds the preview threshold
3233
*
33-
* This approach prevents large command outputs (like build logs, test results, or verbose
34-
* operations) from overwhelming the context window while still allowing the LLM to access
35-
* the full output when needed.
34+
* This approach ensures the LLM sees both:
35+
* - The beginning (command startup, environment info, early errors)
36+
* - The end (final results, exit codes, error summaries)
3637
*
3738
* @example
3839
* ```typescript
@@ -50,17 +51,31 @@ export interface OutputInterceptorOptions {
5051
*
5152
* // Finalize and get the result
5253
* const result = interceptor.finalize();
53-
* // result.preview contains truncated output for display
54+
* // result.preview contains head + [omitted] + tail for display
5455
* // result.artifactPath contains path to full output if truncated
5556
* ```
5657
*/
5758
export class OutputInterceptor {
58-
private buffer: string = ""
59+
/** Buffer for the head (beginning) of output */
60+
private headBuffer: string = ""
61+
/** Buffer for the tail (end) of output - rolling buffer that drops front when full */
62+
private tailBuffer: string = ""
63+
/** Number of bytes currently in the head buffer */
64+
private headBytes: number = 0
65+
/** Number of bytes currently in the tail buffer */
66+
private tailBytes: number = 0
67+
/** Number of bytes omitted from the middle */
68+
private omittedBytes: number = 0
69+
5970
private writeStream: fs.WriteStream | null = null
6071
private artifactPath: string
6172
private totalBytes: number = 0
6273
private spilledToDisk: boolean = false
6374
private readonly previewBytes: number
75+
/** Budget for the head buffer (50% of total preview) */
76+
private readonly headBudget: number
77+
/** Budget for the tail buffer (50% of total preview) */
78+
private readonly tailBudget: number
6479

6580
/**
6681
* Creates a new OutputInterceptor instance.
@@ -69,15 +84,19 @@ export class OutputInterceptor {
6984
*/
7085
constructor(private readonly options: OutputInterceptorOptions) {
7186
this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize]
87+
this.headBudget = Math.floor(this.previewBytes / 2)
88+
this.tailBudget = this.previewBytes - this.headBudget
7289
this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`)
7390
}
7491

7592
/**
7693
* Write a chunk of output to the interceptor.
7794
*
78-
* If the accumulated output exceeds the preview threshold, the interceptor
79-
* automatically spills to disk and switches to streaming mode. Subsequent
80-
* chunks are written directly to the disk file.
95+
* Output is first added to the head buffer until it's full (50% of preview budget).
96+
* Subsequent output goes to a rolling tail buffer that keeps the most recent content.
97+
*
98+
* If the total output exceeds the preview threshold, the interceptor spills to disk
99+
* for full output storage while maintaining head/tail buffers for the preview.
81100
*
82101
* @param chunk - The output string to write
83102
*
@@ -91,18 +110,141 @@ export class OutputInterceptor {
91110
const chunkBytes = Buffer.byteLength(chunk, "utf8")
92111
this.totalBytes += chunkBytes
93112

94-
if (!this.spilledToDisk) {
95-
this.buffer += chunk
113+
// Always update the head/tail preview buffers
114+
this.addToPreviewBuffers(chunk)
96115

97-
if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) {
98-
this.spillToDisk()
116+
// Handle disk spilling for full output preservation
117+
if (!this.spilledToDisk) {
118+
if (this.totalBytes > this.previewBytes) {
119+
this.spillToDisk(chunk)
99120
}
100121
} else {
101122
// Already spilling - write directly to disk
102123
this.writeStream?.write(chunk)
103124
}
104125
}
105126

127+
/**
128+
* Add a chunk to the head/tail preview buffers using 50/50 split strategy.
129+
*
130+
* Fill head first until budget exhausted, then maintain a rolling tail buffer.
131+
*
132+
* @private
133+
*/
134+
private addToPreviewBuffers(chunk: string): void {
135+
let remaining = chunk
136+
let remainingBytes = Buffer.byteLength(chunk, "utf8")
137+
138+
// First, fill the head buffer if there's room
139+
if (this.headBytes < this.headBudget) {
140+
const headRoom = this.headBudget - this.headBytes
141+
if (remainingBytes <= headRoom) {
142+
// Entire chunk fits in head
143+
this.headBuffer += remaining
144+
this.headBytes += remainingBytes
145+
return
146+
}
147+
// Split: part goes to head, rest goes to tail
148+
const headPortion = this.sliceByBytes(remaining, headRoom)
149+
this.headBuffer += headPortion
150+
this.headBytes += headRoom
151+
remaining = remaining.slice(headPortion.length)
152+
remainingBytes = Buffer.byteLength(remaining, "utf8")
153+
}
154+
155+
// Add remainder to tail buffer
156+
this.addToTailBuffer(remaining, remainingBytes)
157+
}
158+
159+
/**
160+
* Add content to the rolling tail buffer, dropping old content as needed.
161+
*
162+
* @private
163+
*/
164+
private addToTailBuffer(chunk: string, chunkBytes: number): void {
165+
if (this.tailBudget === 0) {
166+
this.omittedBytes += chunkBytes
167+
return
168+
}
169+
170+
// If this single chunk is larger than the tail budget, keep only the last tailBudget bytes
171+
if (chunkBytes >= this.tailBudget) {
172+
const dropped = this.tailBytes + (chunkBytes - this.tailBudget)
173+
this.omittedBytes += dropped
174+
this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget)
175+
this.tailBytes = this.tailBudget
176+
return
177+
}
178+
179+
// Append to tail
180+
this.tailBuffer += chunk
181+
this.tailBytes += chunkBytes
182+
183+
// Trim from front if over budget
184+
this.trimTailToFit()
185+
}
186+
187+
/**
188+
* Trim the tail buffer from the front to fit within the tail budget.
189+
*
190+
* @private
191+
*/
192+
private trimTailToFit(): void {
193+
while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) {
194+
const excess = this.tailBytes - this.tailBudget
195+
// Remove characters from the front until we're under budget
196+
// We need to be careful with multi-byte characters
197+
let removed = 0
198+
let removeChars = 0
199+
while (removed < excess && removeChars < this.tailBuffer.length) {
200+
const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8")
201+
removed += charBytes
202+
removeChars++
203+
}
204+
this.omittedBytes += removed
205+
this.tailBytes -= removed
206+
this.tailBuffer = this.tailBuffer.slice(removeChars)
207+
}
208+
}
209+
210+
/**
211+
* Slice a string to get approximately the first N bytes (UTF-8).
212+
*
213+
* @private
214+
*/
215+
private sliceByBytes(str: string, maxBytes: number): string {
216+
let bytes = 0
217+
let i = 0
218+
while (i < str.length && bytes < maxBytes) {
219+
const charBytes = Buffer.byteLength(str[i], "utf8")
220+
if (bytes + charBytes > maxBytes) {
221+
break
222+
}
223+
bytes += charBytes
224+
i++
225+
}
226+
return str.slice(0, i)
227+
}
228+
229+
/**
230+
* Slice a string to get approximately the last N bytes (UTF-8).
231+
*
232+
* @private
233+
*/
234+
private sliceByBytesFromEnd(str: string, maxBytes: number): string {
235+
let bytes = 0
236+
let i = str.length - 1
237+
while (i >= 0 && bytes < maxBytes) {
238+
const charBytes = Buffer.byteLength(str[i], "utf8")
239+
if (bytes + charBytes > maxBytes) {
240+
break
241+
}
242+
bytes += charBytes
243+
i--
244+
}
245+
return str.slice(i + 1)
246+
}
247+
106248
/**
107249
* Spill buffered content to disk and switch to streaming mode.
108250
*
@@ -112,26 +254,39 @@ export class OutputInterceptor {
112254
*
113255
* @private
114256
*/
115-
private spillToDisk(): void {
257+
private spillToDisk(currentChunk: string): void {
116258
// Ensure directory exists
117259
const dir = path.dirname(this.artifactPath)
118260
if (!fs.existsSync(dir)) {
119261
fs.mkdirSync(dir, { recursive: true })
120262
}
121263

122264
this.writeStream = fs.createWriteStream(this.artifactPath)
123-
this.writeStream.write(this.buffer)
124-
this.spilledToDisk = true
265+
// Write the full head buffer + any tail content accumulated so far
266+
// Note: We need to reconstruct full output seen so far
267+
// The full content before this chunk is: totalBytes - currentChunkBytes
268+
// But we've already been tracking head/tail, so we write head + omitted + tail + current
269+
// Actually, we need to write the complete original content
270+
// Since we're spilling on the chunk that pushes us over, we need to write everything
271+
// that came before plus this chunk
272+
273+
// Reconstruct: we have headBuffer (complete head) + whatever was in tail before trimming
274+
// For simplicity, write head + tail + current chunk (the tail already has some data)
275+
this.writeStream.write(this.headBuffer)
276+
if (this.tailBuffer.length > 0) {
277+
this.writeStream.write(this.tailBuffer)
278+
}
279+
// Don't write currentChunk here - it was already processed into head/tail buffers
280+
// and will be written via the streaming path
125281

126-
// Keep only preview portion in memory
127-
this.buffer = this.buffer.slice(0, this.previewBytes)
282+
this.spilledToDisk = true
128283
}
129284

130285
/**
131286
* Finalize the interceptor and return the persisted output result.
132287
*
133288
* Closes any open file streams and returns a summary object containing:
134-
* - A preview of the output (truncated to preview size)
289+
* - A preview of the output (head + [omitted indicator] + tail)
135290
* - The total byte count of all output
136291
* - The path to the full output file (if truncated)
137292
* - A flag indicating whether the output was truncated
@@ -154,8 +309,15 @@ export class OutputInterceptor {
154309
this.writeStream.end()
155310
}
156311

157-
// Prepare preview
158-
const preview = this.buffer.slice(0, this.previewBytes)
312+
// Prepare preview: head + [omission indicator] + tail
313+
let preview: string
314+
if (this.omittedBytes > 0) {
315+
const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n`
316+
preview = this.headBuffer + omissionIndicator + this.tailBuffer
317+
} else {
318+
// No truncation, just combine head and tail (or head alone if tail is empty)
319+
preview = this.headBuffer + this.tailBuffer
320+
}
159321

160322
return {
161323
preview,
@@ -168,13 +330,15 @@ export class OutputInterceptor {
168330
/**
169331
* Get the current buffer content for UI display.
170332
*
171-
* Returns the in-memory buffer which contains either all output (if not spilled)
172-
* or just the preview portion (if spilled to disk).
333+
* Returns the combined head + tail content for real-time UI updates.
334+
* Note: Does not include the omission indicator to avoid flickering during streaming.
173335
*
174336
* @returns The current buffer content as a string
175337
*/
176338
getBufferForUI(): string {
177-
return this.buffer
339+
// For UI, return combined head + tail without omission indicator
340+
// This provides a smoother streaming experience
341+
return this.headBuffer + this.tailBuffer
178342
}
179343

180344
/**

0 commit comments

Comments
 (0)