From d8b7a0a3910382f7d9c53a6c9024c6ca91c03ff6 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:38:50 +0000 Subject: [PATCH] fix(core): preserve shell output tail Shell truncation read from cursor zero, so large command output kept the head instead of the useful tail. Read the final capture window and cover the direction with head/tail markers. --- packages/client/src/effect/api/api.ts | 1 + packages/client/src/effect/generated/client.ts | 3 ++- packages/client/src/promise/generated/client.ts | 2 +- packages/client/src/promise/generated/types.ts | 9 +++++++++ packages/core/src/shell.ts | 5 +++-- packages/core/src/tool/shell.ts | 4 ++-- packages/core/test/tool-shell.test.ts | 13 ++++++++----- packages/schema/src/shell.ts | 1 + packages/server/src/handlers/shell.ts | 6 +++++- 9 files changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 28c06cbf04ff..0b9937f7079e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -809,6 +809,7 @@ export type Endpoint21_4Input = { readonly location?: Endpoint21_4Request["query"]["location"] readonly cursor?: Endpoint21_4Request["query"]["cursor"] readonly limit?: Endpoint21_4Request["query"]["limit"] + readonly keep?: Endpoint21_4Request["query"]["keep"] } export type Endpoint21_4Output = EffectValue> export type ShellOutputOperation = (input: Endpoint21_4Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 1f5871711356..d56562d48244 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -971,11 +971,12 @@ type Endpoint21_4Input = { readonly location?: Endpoint21_4Request["query"]["location"] readonly cursor?: Endpoint21_4Request["query"]["cursor"] readonly limit?: Endpoint21_4Request["query"]["limit"] + readonly keep?: Endpoint21_4Request["query"]["keep"] } const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) => raw["shell.output"]({ params: { id: input["id"] }, - query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, + query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] }, }).pipe(Effect.mapError(mapClientError)) type Endpoint21_5Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index bed325d97689..b0a574067f7a 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1411,7 +1411,7 @@ export function make(options: ClientOptions) { { method: "GET", path: `/api/shell/${encodeURIComponent(input.id)}/output`, - query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, + query: { location: input["location"], cursor: input["cursor"], limit: input["limit"], keep: input["keep"] }, successStatus: 200, declaredStatuses: [404, 401, 400], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 255beaa1bb9d..878e3dc70682 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4623,17 +4623,26 @@ export type ShellOutputInput = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly cursor?: number | undefined readonly limit?: number | undefined + readonly keep?: "head" | "tail" | undefined }["location"] readonly cursor?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly cursor?: number | undefined readonly limit?: number | undefined + readonly keep?: "head" | "tail" | undefined }["cursor"] readonly limit?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly cursor?: number | undefined readonly limit?: number | undefined + readonly keep?: "head" | "tail" | undefined }["limit"] + readonly keep?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + readonly cursor?: number | undefined + readonly limit?: number | undefined + readonly keep?: "head" | "tail" | undefined + }["keep"] } export type ShellOutputOutput = { diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index a62fef8107fe..6e0987507699 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -139,8 +139,9 @@ export const layer = Layer.effect( const cursor = input?.cursor ?? 0 const limit = input?.limit ?? 65536 if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false } - const start = Math.max(0, cursor) - const length = Math.min(limit, session.size - start) + const available = session.size - Math.max(0, cursor) + const start = input?.keep === "tail" ? Math.max(cursor, session.size - limit) : Math.max(0, cursor) + const length = Math.min(limit, available) const buffer = Buffer.alloc(length) const bytesRead = yield* Effect.promise( () => diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index d2b8bb474482..54dffd3d966b 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -201,7 +201,7 @@ export const Plugin = { const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES, keep: "tail" }) if (final.status === "timeout") { return { @@ -213,7 +213,7 @@ export const Plugin = { } } - const truncated = page.size > page.cursor + const truncated = page.size > MAX_CAPTURE_BYTES const body = page.output || "(no output)" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" return { diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 6a6a53e171d8..8c16bb7af283 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -162,10 +162,10 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60" const bodyExitCommand = isWindows ? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7" : "printf body && exit 7" -const overflowCommand = (bytes: number) => +const overflowMarkersCommand = (bytes: number) => isWindows - ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` - : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` + ? `[Console]::Out.Write('head-marker'); [Console]::Out.Write(('x' * ${bytes})); [Console]::Out.Write('tail-marker'); Start-Sleep -Milliseconds 100` + : `printf head-marker; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf tail-marker` const withSession = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => Effect.gen(function* () { @@ -396,15 +396,18 @@ describe("ShellTool", () => { reset() const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 return withSession(tmp.path, (registry) => - settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), + settleTool(registry, call({ command: overflowMarkersCommand(bytes) }, "call-overflow")), ).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true }) expect(settled.output?.content[0]).toMatchObject({ type: "text", - text: expect.stringContaining("output truncated; full output saved to:"), + text: expect.stringMatching(/tail-marker[\s\S]*output truncated; full output saved to:/), }) + const content = settled.output?.content[0] + if (content?.type === "text" && typeof content.text === "string") + expect(content.text.includes("head-marker")).toBe(false) }), ), ) diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index bcd09cd901ac..ce9b36a82cab 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -65,6 +65,7 @@ export interface CreateInput extends Schema.Schema.Type {} export const OutputInput = Schema.Struct({ cursor: optional(NonNegativeInt), limit: optional(NonNegativeInt), + keep: optional(Schema.Literals(["head", "tail"])), }) export interface OutputInput extends Schema.Schema.Type {} diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts index ac1ae09a8c23..4cae3d575e77 100644 --- a/packages/server/src/handlers/shell.ts +++ b/packages/server/src/handlers/shell.ts @@ -57,7 +57,11 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) Effect.fn(function* (ctx) { const shell = yield* Shell.Service return yield* response( - shell.output(ctx.params.id, { cursor: ctx.query.cursor, limit: ctx.query.limit }).pipe( + shell.output(ctx.params.id, { + cursor: ctx.query.cursor, + limit: ctx.query.limit, + keep: ctx.query.keep, + }).pipe( Effect.catchTag( "Shell.NotFoundError", () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),