From 4778e6e03127965c8f0b12441a29121621dbac7f Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 16 Jul 2026 20:24:54 +0000 Subject: [PATCH] fix(core): stream shell progress tail --- packages/core/src/tool/shell.ts | 20 +++++++++++++++++++- packages/core/test/tool-shell.test.ts | 19 +++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 300acf8cd5..37c0d37b8e 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -17,6 +17,7 @@ export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 +export const PROGRESS_LINES = 25 const BACKGROUND_STARTED = "The command was moved to the background." const BACKGROUND_INSTRUCTION = @@ -210,6 +211,23 @@ export const Plugin = { } }) + const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () { + const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER }) + const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES) + const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES }) + const trailingNewline = page.output.endsWith("\n") + const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n") + const truncated = start > 0 || lines.length > PROGRESS_LINES + const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "") + const notice = truncated + ? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n` + : "" + return { + output: `${notice}${output || "(no output)"}`, + truncated, + } + }) + const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const final = yield* shell.wait(info.id) @@ -258,7 +276,7 @@ export const Plugin = { const progress = yield* Effect.sleep("1 second").pipe( Effect.andThen( - captureShell().pipe( + captureProgress().pipe( Effect.flatMap((capture) => context.progress({ structured: { truncated: capture.truncated }, diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 007099b000..ad4795a259 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) => isWindows ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` -const progressOverflowCommand = (bytes: number) => +const progressLinesCommand = (lines: number) => isWindows - ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500` - : `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5` + ? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500` + : `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5` const withSession = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => Effect.gen(function* () { @@ -417,17 +417,17 @@ describe("ShellTool", () => { ), ) - it.live("reports bounded output progress for a running command", () => + it.live("reports the latest output lines for a running command", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() - const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 + const lines = ShellTool.PROGRESS_LINES + 10 return withSession(tmp.path, (registry) => Effect.gen(function* () { const progress: ToolRegistry.Progress[] = [] yield* settleTool(registry, { - ...call({ command: progressOverflowCommand(bytes) }, "call-progress"), + ...call({ command: progressLinesCommand(lines) }, "call-progress"), progress: (update) => Effect.sync(() => progress.push(update)), }) @@ -436,9 +436,12 @@ describe("ShellTool", () => { const content = progress[0]?.content[0] expect(content?.type).toBe("text") if (content?.type !== "text") return - expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( - ShellTool.MAX_CAPTURE_BYTES, + expect(content.text).toStartWith( + `[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`, ) + expect(content.text).not.toContain("line 10\n") + expect(content.text).toContain("line 11\n") + expect(content.text).toContain(`line ${lines}\n`) }), ) },