فهرست منبع

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.
Aiden Cline 1 ماه پیش
والد
کامیت
d8b7a0a391

+ 1 - 0
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<ReturnType<RawClient["server.shell"]["shell.output"]>>
 export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effect.Effect<Endpoint21_4Output, E>

+ 2 - 1
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<RawClient["server.shell"]["shell.remove"]>[0]

+ 1 - 1
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,

+ 9 - 0
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 = {

+ 3 - 2
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(
         () =>

+ 2 - 2
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 {

+ 8 - 5
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 = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
   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)
             }),
           ),
         )

+ 1 - 0
packages/schema/src/shell.ts

@@ -65,6 +65,7 @@ export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
 export const OutputInput = Schema.Struct({
   cursor: optional(NonNegativeInt),
   limit: optional(NonNegativeInt),
+  keep: optional(Schema.Literals(["head", "tail"])),
 })
 export interface OutputInput extends Schema.Schema.Type<typeof OutputInput> {}
 

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