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

Commit 61e7820

Browse files
committed
fix: address review feedback for lossless terminal output
- OutputInterceptor.finalize() now awaits stream flush before returning This ensures artifact files are fully written before the artifact_id is advertised to the LLM, preventing partial reads. - Remove strict mode from read_command_output native tool schema With strict: true, OpenAI requires all params in 'required', forcing the LLM to provide explicit null values for optional params. This created verbose tool calls. Now optional params can be omitted entirely. - Update tests to handle async finalize() method
1 parent 765528e commit 61e7820

4 files changed

Lines changed: 52 additions & 37 deletions

File tree

src/core/prompts/tools/native-tools/read_command_output.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ export default {
4949
function: {
5050
name: "read_command_output",
5151
description: READ_COMMAND_OUTPUT_DESCRIPTION,
52-
strict: true,
52+
// Note: strict mode is intentionally disabled for this tool.
53+
// With strict: true, OpenAI requires ALL properties to be in the 'required' array,
54+
// which forces the LLM to always provide explicit values (even null) for optional params.
55+
// This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can
56+
// omit optional parameters entirely, making the tool easier to use.
5357
parameters: {
5458
type: "object",
5559
properties: {
@@ -58,21 +62,19 @@ export default {
5862
description: ARTIFACT_ID_DESCRIPTION,
5963
},
6064
search: {
61-
type: ["string", "null"],
65+
type: "string",
6266
description: SEARCH_DESCRIPTION,
6367
},
6468
offset: {
65-
type: ["number", "null"],
69+
type: "number",
6670
description: OFFSET_DESCRIPTION,
6771
},
6872
limit: {
69-
type: ["number", "null"],
73+
type: "number",
7074
description: LIMIT_DESCRIPTION,
7175
},
7276
},
73-
// With strict: true, ALL properties must be listed in required.
74-
// Optional params use union type with null (e.g., ["string", "null"]).
75-
required: ["artifact_id", "search", "offset", "limit"],
77+
required: ["artifact_id"],
7678
additionalProperties: false,
7779
},
7880
},

src/core/tools/ExecuteCommandTool.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,12 @@ export async function executeCommandInTerminal(
246246
// Silently handle ask errors (e.g., "Current ask promise was ignored")
247247
}
248248
},
249-
onCompleted: (output: string | undefined) => {
250-
// Finalize interceptor and get persisted result
249+
onCompleted: async (output: string | undefined) => {
250+
// Finalize interceptor and get persisted result.
251+
// We await finalize() to ensure the artifact file is fully flushed
252+
// before we advertise the artifact_id to the LLM.
251253
if (interceptor) {
252-
persistedResult = interceptor.finalize()
254+
persistedResult = await interceptor.finalize()
253255
}
254256

255257
// Continue using compressed output for UI display

src/integrations/terminal/OutputInterceptor.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ export class OutputInterceptor {
288288
/**
289289
* Finalize the interceptor and return the persisted output result.
290290
*
291-
* Closes any open file streams and returns a summary object containing:
291+
* Closes any open file streams and waits for them to fully flush before returning.
292+
* This ensures the artifact file is completely written and ready for reading.
293+
*
294+
* Returns a summary object containing:
292295
* - A preview of the output (head + [omitted indicator] + tail)
293296
* - The total byte count of all output
294297
* - The path to the full output file (if truncated)
@@ -298,18 +301,22 @@ export class OutputInterceptor {
298301
*
299302
* @example
300303
* ```typescript
301-
* const result = interceptor.finalize();
304+
* const result = await interceptor.finalize();
302305
* console.log(`Preview: ${result.preview}`);
303306
* console.log(`Total bytes: ${result.totalBytes}`);
304307
* if (result.truncated) {
305308
* console.log(`Full output at: ${result.artifactPath}`);
306309
* }
307310
* ```
308311
*/
309-
finalize(): PersistedCommandOutput {
310-
// Close write stream if open
312+
async finalize(): Promise<PersistedCommandOutput> {
313+
// Close write stream if open and wait for it to fully flush.
314+
// This ensures the artifact is completely written before we advertise the artifact_id.
311315
if (this.writeStream) {
312-
this.writeStream.end()
316+
await new Promise<void>((resolve, reject) => {
317+
this.writeStream!.end(() => resolve())
318+
this.writeStream!.on("error", reject)
319+
})
313320
}
314321

315322
// Prepare preview: head + [omission indicator] + tail

src/integrations/terminal/__tests__/OutputInterceptor.test.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@ describe("OutputInterceptor", () => {
3434

3535
storageDir = path.normalize("/tmp/test-storage")
3636

37-
// Setup mock write stream
37+
// Setup mock write stream with callback support for end()
3838
mockWriteStream = {
3939
write: vi.fn(),
40-
end: vi.fn(),
40+
end: vi.fn((callback?: () => void) => {
41+
// Immediately call the callback to simulate stream flush completing
42+
if (callback) callback()
43+
}),
44+
on: vi.fn(),
4145
}
4246

4347
vi.mocked(fs.existsSync).mockReturnValue(true)
@@ -49,7 +53,7 @@ describe("OutputInterceptor", () => {
4953
})
5054

5155
describe("Buffering behavior", () => {
52-
it("should keep small output in memory without spilling to disk", () => {
56+
it("should keep small output in memory without spilling to disk", async () => {
5357
const interceptor = new OutputInterceptor({
5458
executionId: "12345",
5559
taskId: "task-1",
@@ -64,7 +68,7 @@ describe("OutputInterceptor", () => {
6468
expect(interceptor.hasSpilledToDisk()).toBe(false)
6569
expect(fs.createWriteStream).not.toHaveBeenCalled()
6670

67-
const result = interceptor.finalize()
71+
const result = await interceptor.finalize()
6872
expect(result.preview).toBe(smallOutput)
6973
expect(result.truncated).toBe(false)
7074
expect(result.artifactPath).toBe(null)
@@ -94,7 +98,7 @@ describe("OutputInterceptor", () => {
9498
expect(mockWriteStream.write).toHaveBeenCalled()
9599
})
96100

97-
it("should truncate preview after spilling to disk using head/tail split", () => {
101+
it("should truncate preview after spilling to disk using head/tail split", async () => {
98102
const interceptor = new OutputInterceptor({
99103
executionId: "12345",
100104
taskId: "task-1",
@@ -109,7 +113,7 @@ describe("OutputInterceptor", () => {
109113

110114
expect(interceptor.hasSpilledToDisk()).toBe(true)
111115

112-
const result = interceptor.finalize()
116+
const result = await interceptor.finalize()
113117
expect(result.truncated).toBe(true)
114118
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
115119
// Preview is head (1024) + omission indicator + tail (1024)
@@ -268,7 +272,7 @@ describe("OutputInterceptor", () => {
268272
})
269273

270274
describe("finalize() method", () => {
271-
it("should return preview output for small commands", () => {
275+
it("should return preview output for small commands", async () => {
272276
const interceptor = new OutputInterceptor({
273277
executionId: "12345",
274278
taskId: "task-1",
@@ -280,15 +284,15 @@ describe("OutputInterceptor", () => {
280284
const output = "Hello World\n"
281285
interceptor.write(output)
282286

283-
const result = interceptor.finalize()
287+
const result = await interceptor.finalize()
284288

285289
expect(result.preview).toBe(output)
286290
expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8"))
287291
expect(result.artifactPath).toBe(null)
288292
expect(result.truncated).toBe(false)
289293
})
290294

291-
it("should return PersistedCommandOutput for large commands with head/tail preview", () => {
295+
it("should return PersistedCommandOutput for large commands with head/tail preview", async () => {
292296
const interceptor = new OutputInterceptor({
293297
executionId: "12345",
294298
taskId: "task-1",
@@ -300,7 +304,7 @@ describe("OutputInterceptor", () => {
300304
const largeOutput = "x".repeat(5000)
301305
interceptor.write(largeOutput)
302306

303-
const result = interceptor.finalize()
307+
const result = await interceptor.finalize()
304308

305309
expect(result.truncated).toBe(true)
306310
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
@@ -310,7 +314,7 @@ describe("OutputInterceptor", () => {
310314
expect(result.preview).toContain("bytes omitted...]")
311315
})
312316

313-
it("should close write stream when finalizing", () => {
317+
it("should close write stream when finalizing", async () => {
314318
const interceptor = new OutputInterceptor({
315319
executionId: "12345",
316320
taskId: "task-1",
@@ -321,12 +325,12 @@ describe("OutputInterceptor", () => {
321325

322326
// Trigger spill
323327
interceptor.write("x".repeat(3000))
324-
interceptor.finalize()
328+
await interceptor.finalize()
325329

326330
expect(mockWriteStream.end).toHaveBeenCalled()
327331
})
328332

329-
it("should include correct metadata (artifactId, size, truncated flag)", () => {
333+
it("should include correct metadata (artifactId, size, truncated flag)", async () => {
330334
const interceptor = new OutputInterceptor({
331335
executionId: "12345",
332336
taskId: "task-1",
@@ -338,7 +342,7 @@ describe("OutputInterceptor", () => {
338342
const output = "x".repeat(5000)
339343
interceptor.write(output)
340344

341-
const result = interceptor.finalize()
345+
const result = await interceptor.finalize()
342346

343347
expect(result).toHaveProperty("preview")
344348
expect(result).toHaveProperty("totalBytes", 5000)
@@ -432,7 +436,7 @@ describe("OutputInterceptor", () => {
432436
})
433437

434438
describe("Head/Tail split behavior", () => {
435-
it("should preserve first 50% and last 50% of output", () => {
439+
it("should preserve first 50% and last 50% of output", async () => {
436440
const interceptor = new OutputInterceptor({
437441
executionId: "12345",
438442
taskId: "task-1",
@@ -450,7 +454,7 @@ describe("OutputInterceptor", () => {
450454
interceptor.write(middleContent)
451455
interceptor.write(tailContent)
452456

453-
const result = interceptor.finalize()
457+
const result = await interceptor.finalize()
454458

455459
// Should start with HEAD content (first 1024 bytes of head budget)
456460
expect(result.preview.startsWith("HEAD")).toBe(true)
@@ -461,7 +465,7 @@ describe("OutputInterceptor", () => {
461465
expect(result.preview).toContain("bytes omitted...]")
462466
})
463467

464-
it("should not add omission indicator when output fits in budget", () => {
468+
it("should not add omission indicator when output fits in budget", async () => {
465469
const interceptor = new OutputInterceptor({
466470
executionId: "12345",
467471
taskId: "task-1",
@@ -473,14 +477,14 @@ describe("OutputInterceptor", () => {
473477
const smallOutput = "Hello World\n"
474478
interceptor.write(smallOutput)
475479

476-
const result = interceptor.finalize()
480+
const result = await interceptor.finalize()
477481

478482
// No omission indicator for small output
479483
expect(result.preview).toBe(smallOutput)
480484
expect(result.preview).not.toContain("[...")
481485
})
482486

483-
it("should handle output that exactly fills head budget", () => {
487+
it("should handle output that exactly fills head budget", async () => {
484488
const interceptor = new OutputInterceptor({
485489
executionId: "12345",
486490
taskId: "task-1",
@@ -493,14 +497,14 @@ describe("OutputInterceptor", () => {
493497
const exactHeadContent = "x".repeat(1024)
494498
interceptor.write(exactHeadContent)
495499

496-
const result = interceptor.finalize()
500+
const result = await interceptor.finalize()
497501

498502
// Should fit entirely in head, no truncation
499503
expect(result.preview).toBe(exactHeadContent)
500504
expect(result.truncated).toBe(false)
501505
})
502506

503-
it("should split single large chunk across head and tail", () => {
507+
it("should split single large chunk across head and tail", async () => {
504508
const interceptor = new OutputInterceptor({
505509
executionId: "12345",
506510
taskId: "task-1",
@@ -514,7 +518,7 @@ describe("OutputInterceptor", () => {
514518
const content = "A".repeat(1024) + "B".repeat(2000) + "C".repeat(1024)
515519
interceptor.write(content)
516520

517-
const result = interceptor.finalize()
521+
const result = await interceptor.finalize()
518522

519523
// Head should have A's
520524
expect(result.preview.startsWith("A")).toBe(true)

0 commit comments

Comments
 (0)