Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline d8b7a0a391 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.
2026-07-12 20:03:52 +00:00
9 changed files with 32 additions and 12 deletions
+1
View File
@@ -809,6 +809,7 @@ export type Endpoint21_4Input = {
readonly location?: Endpoint21_4Request["query"]["location"] readonly location?: Endpoint21_4Request["query"]["location"]
readonly cursor?: Endpoint21_4Request["query"]["cursor"] readonly cursor?: Endpoint21_4Request["query"]["cursor"]
readonly limit?: Endpoint21_4Request["query"]["limit"] readonly limit?: Endpoint21_4Request["query"]["limit"]
readonly keep?: Endpoint21_4Request["query"]["keep"]
} }
export type Endpoint21_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>> export type Endpoint21_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E> export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>
@@ -971,11 +971,12 @@ type Endpoint21_4Input = {
readonly location?: Endpoint21_4Request["query"]["location"] readonly location?: Endpoint21_4Request["query"]["location"]
readonly cursor?: Endpoint21_4Request["query"]["cursor"] readonly cursor?: Endpoint21_4Request["query"]["cursor"]
readonly limit?: Endpoint21_4Request["query"]["limit"] readonly limit?: Endpoint21_4Request["query"]["limit"]
readonly keep?: Endpoint21_4Request["query"]["keep"]
} }
const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) => const Endpoint21_4 = (raw: RawClient["server.shell"]) => (input: Endpoint21_4Input) =>
raw["shell.output"]({ raw["shell.output"]({
params: { id: input["id"] }, 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)) }).pipe(Effect.mapError(mapClientError))
type Endpoint21_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] type Endpoint21_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
@@ -1411,7 +1411,7 @@ export function make(options: ClientOptions) {
{ {
method: "GET", method: "GET",
path: `/api/shell/${encodeURIComponent(input.id)}/output`, 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, successStatus: 200,
declaredStatuses: [404, 401, 400], declaredStatuses: [404, 401, 400],
empty: false, empty: false,
@@ -4623,17 +4623,26 @@ export type ShellOutputInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly keep?: "head" | "tail" | undefined
}["location"] }["location"]
readonly cursor?: { readonly cursor?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly keep?: "head" | "tail" | undefined
}["cursor"] }["cursor"]
readonly limit?: { readonly limit?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined readonly cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly keep?: "head" | "tail" | undefined
}["limit"] }["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 = { export type ShellOutputOutput = {
+3 -2
View File
@@ -139,8 +139,9 @@ export const layer = Layer.effect(
const cursor = input?.cursor ?? 0 const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536 const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false } if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor) const available = session.size - Math.max(0, cursor)
const length = Math.min(limit, session.size - start) 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 buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise( const bytesRead = yield* Effect.promise(
() => () =>
+2 -2
View File
@@ -201,7 +201,7 @@ export const Plugin = {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) 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") { if (final.status === "timeout") {
return { 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 body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return { return {
+8 -5
View File
@@ -162,10 +162,10 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const bodyExitCommand = isWindows const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7" ? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7" : "printf body && exit 7"
const overflowCommand = (bytes: number) => const overflowMarkersCommand = (bytes: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` ? `[Console]::Out.Write('head-marker'); [Console]::Out.Write(('x' * ${bytes})); [Console]::Out.Write('tail-marker'); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` : `printf head-marker; head -c ${bytes} /dev/zero | tr '\\0' 'x'; printf tail-marker`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -396,15 +396,18 @@ describe("ShellTool", () => {
reset() reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) => return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), settleTool(registry, call({ command: overflowMarkersCommand(bytes) }, "call-overflow")),
).pipe( ).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true }) expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({ expect(settled.output?.content[0]).toMatchObject({
type: "text", 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)
}), }),
), ),
) )
+1
View File
@@ -65,6 +65,7 @@ export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const OutputInput = Schema.Struct({ export const OutputInput = Schema.Struct({
cursor: optional(NonNegativeInt), cursor: optional(NonNegativeInt),
limit: optional(NonNegativeInt), limit: optional(NonNegativeInt),
keep: optional(Schema.Literals(["head", "tail"])),
}) })
export interface OutputInput extends Schema.Schema.Type<typeof OutputInput> {} export interface OutputInput extends Schema.Schema.Type<typeof OutputInput> {}
+5 -1
View File
@@ -57,7 +57,11 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
const shell = yield* Shell.Service const shell = yield* Shell.Service
return yield* response( 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( Effect.catchTag(
"Shell.NotFoundError", "Shell.NotFoundError",
() => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }),