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}` }),