Read the nested run output envelope in `openagents box`

9e5d978d1eca · AtlantisPleb · · parent a524bec9a92d

Read the nested run output envelope in `openagents box`

`box runs output` printed an empty line against production while every byte
was durable server-side. `BoxRunController.output/2` answers
`{"run_id": …, "output": {"output": …, "next_offset": …, "truncated": …}}`,
and the client read the envelope's `output` as text, so `asText` saw an
object and fell through to `""`.

Read the nested record, keep a fall back to a bare string so a deployment
still answering the flat shape keeps working, and surface `next_offset` and
`truncated` — the `--offset` flag had no way to learn where to resume, and a
box that drops bytes before the requested offset now says so instead of
letting the gap read as the run's first line.

Also retire the refusal that claimed the account had no conversation. No
deployed endpoint resolves it: `GET /api/v1/user` answers the forge identity
without a `conversation_id`, and it sits behind `forge:write` rather than the
`box:control` scope a box token carries. The probe stays, so a deployment
that grows the field starts working without a client release, but the
refusal now names `--conversation`, which is what unblocks the caller today.

Verified live against production: `box runs output` on run
`02d344a9-504c-4b95-b93c-5a421b59cdc6` now renders the durable log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/box-client.ts
  • modified packages/openagents-cli/src/box-command.ts
  • modified packages/openagents-cli/test/box-command.test.ts

Diff

3 files changed, +196 -5

packages/openagents-cli/src/box-client.ts modified +32 -4

@@ -13,7 +13,7 @@ import { ApiTransport } from "./api-transport.js";

13 13
import { API_VERSION_PATH } from "./constants.js";
14 14
import { ApiError, type CliError } from "./errors.js";
15 15
import type { AuthenticatedApi } from "./repository-client.js";
16
import { asRecord, asRows, asText, makeTrackerRequest } from "./tracker-request.js";
16
import { asNumber, asRecord, asRows, asText, makeTrackerRequest } from "./tracker-request.js";
17 17
18 18
export interface BoxRecord {
19 19
  readonly box_id: string;

@@ -119,6 +119,15 @@ export interface BoxRunViewInput extends AuthenticatedApi {

119 119
  readonly runId: string;
120 120
}
121 121
122
export interface BoxRunOutput {
123
  readonly run_id: string;
124
  readonly output: string;
125
  /** The offset to pass to the next read to resume where this one stopped. */
126
  readonly next_offset: number;
127
  /** True when the box dropped bytes before the requested offset. */
128
  readonly truncated: boolean;
129
}
130
122 131
export interface BoxRunOutputInput extends AuthenticatedApi {
123 132
  readonly conversationId?: string;
124 133
  readonly boxId: string;

@@ -154,7 +163,7 @@ export interface BoxClientInterface {

154 163
  readonly startRun: (input: BoxRunCreateInput) => Effect.Effect<BoxRunRecord, CliError>;
155 164
  readonly listRuns: (input: BoxRunListInput) => Effect.Effect<ReadonlyArray<BoxRunRecord>, CliError>;
156 165
  readonly viewRun: (input: BoxRunViewInput) => Effect.Effect<BoxRunRecord, CliError>;
157
  readonly runOutput: (input: BoxRunOutputInput) => Effect.Effect<{ run_id: string; output: string }, CliError>;
166
  readonly runOutput: (input: BoxRunOutputInput) => Effect.Effect<BoxRunOutput, CliError>;
158 167
  readonly cancelRun: (input: BoxRunCancelInput) => Effect.Effect<BoxRunRecord, CliError>;
159 168
  readonly fanout: (input: BoxFanoutInput) => Effect.Effect<BoxFanoutPlan, CliError>;
160 169
  readonly viewFanout: (input: BoxFanoutViewInput) => Effect.Effect<BoxFanoutPlan, CliError>;

@@ -170,6 +179,14 @@ export const boxClientLayer = Layer.effect(

170 179
    const transport = yield* ApiTransport;
171 180
    const request = makeTrackerRequest(transport);
172 181
182
    // No deployed endpoint resolves the account's conversation yet. `GET
183
    // /api/v1/user` answers the forge identity and never carries a
184
    // `conversation_id`, and it sits behind `forge:write` rather than the
185
    // `box:control` scope a box token carries, so this probe fails twice over
186
    // against production. The probe stays because a deployment that grows the
187
    // field should start working without a client release, but the refusal
188
    // now names the flag that actually gets the caller unblocked instead of
189
    // claiming the account has no conversation.
173 190
    const resolveConversationId = Effect.fn("BoxClient.resolveConversationId")(function* (
174 191
      input: AuthenticatedApi,
175 192
    ) {

@@ -187,7 +204,9 @@ export const boxClientLayer = Layer.effect(

187 204
      return yield* new ApiError({
188 205
        operation: "resolve user conversation",
189 206
        status: response.status,
190
        message: "Could not find an active conversation for this account.",
207
        message:
208
          "This deployment does not report a conversation for the account. " +
209
          "Pass --conversation <conversation_id> to name the conversation to use.",
191 210
      });
192 211
    });
193 212

@@ -410,10 +429,19 @@ export const boxClientLayer = Layer.effect(

410 429
        path: `${API_VERSION_PATH}/conversations/${encodeURIComponent(convId)}/boxes/${encodeURIComponent(input.boxId)}/runs/${encodeURIComponent(input.runId)}/output${query}`,
411 430
        acceptedStatuses: [200],
412 431
      });
432
      // The server nests the read under `output`: the envelope is
433
      // `{"run_id": …, "output": {"output": …, "next_offset": …, "truncated": …}}`,
434
      // so reading the envelope's `output` as text yielded an empty string and
435
      // `box runs output` printed nothing. Read the nested record, and fall
436
      // back to a bare string for any older deployment still answering flat.
413 437
      const res = asRecord(body);
438
      const nested = asRecord(res["output"]);
439
      const flat = asText(res["output"]);
414 440
      return {
415 441
        run_id: asText(res["run_id"]) ?? input.runId,
416
        output: asText(res["output"]) ?? "",
442
        output: flat ?? asText(nested["output"]) ?? "",
443
        next_offset: asNumber(nested["next_offset"]) ?? input.offset ?? 0,
444
        truncated: nested["truncated"] === true,
417 445
      };
418 446
    });
419 447
packages/openagents-cli/src/box-command.ts modified +7 -1

@@ -385,7 +385,13 @@ export const makeBoxCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) =>

385 385
        yield* output.write(
386 386
          {
387 387
            value: result,
388
            human: [result.output],
388
            human: [
389
              // The box keeps a bounded log, so a read that starts before the
390
              // retained window silently begins mid-stream. Say so rather than
391
              // letting the gap read as the run's first line.
392
              ...(result.truncated ? ["[EARLIER OUTPUT DROPPED BY THE BOX]"] : []),
393
              result.output.trimEnd(),
394
            ],
389 395
          },
390 396
          outputMode(flags.json),
391 397
        );
packages/openagents-cli/test/box-command.test.ts modified +157

@@ -186,4 +186,161 @@ describe("openagents box CLI commands", () => {

186 186
    const human = written[0]!.document.human;
187 187
    expect(human.some((line) => line.includes("Stopped Box bx_test123"))).toBe(true);
188 188
  });
189
190
  // The server nests the read one level down, under `output.output`. Reading
191
  // the envelope's `output` as text made `box runs output` print an empty
192
  // line against production while every record was durable server-side.
193
  it("renders the nested run output envelope the server sends", async () => {
194
    const { layer, written } = harness((req) => {
195
      if (req.path === "/api/v1/user") {
196
        return Effect.succeed({
197
          status: 200,
198
          body: { conversation_id: "conv-123" },
199
        });
200
      }
201
      if (req.path === "/api/v1/conversations/conv-123/boxes/bx_test123/runs/run-9/output") {
202
        return Effect.succeed({
203
          status: 200,
204
          body: {
205
            run_id: "run-9",
206
            output: {
207
              output: "cloned openagents.com at 40dbd832\n",
208
              offset: 0,
209
              next_offset: 34,
210
              output_base_offset: 0,
211
              truncated: false,
212
            },
213
          },
214
        });
215
      }
216
      return Effect.succeed({ status: 404, body: {} });
217
    });
218
219
    await Effect.runPromise(
220
      runCliWith(["box", "runs", "output", "bx_test123", "run-9"]).pipe(Effect.provide(layer)),
221
    );
222
223
    expect(written.length).toBe(1);
224
    const human = written[0]!.document.human;
225
    expect(human.some((line) => line.includes("cloned openagents.com at 40dbd832"))).toBe(true);
226
    expect(human.some((line) => line.includes("EARLIER OUTPUT DROPPED"))).toBe(false);
227
    expect(written[0]!.document.value).toMatchObject({
228
      run_id: "run-9",
229
      next_offset: 34,
230
      truncated: false,
231
    });
232
  });
233
234
  it("reports output the box dropped before the requested offset", async () => {
235
    const { layer, written } = harness((req) => {
236
      if (req.path === "/api/v1/user") {
237
        return Effect.succeed({
238
          status: 200,
239
          body: { conversation_id: "conv-123" },
240
        });
241
      }
242
      if (
243
        req.path === "/api/v1/conversations/conv-123/boxes/bx_test123/runs/run-9/output?offset=10"
244
      ) {
245
        return Effect.succeed({
246
          status: 200,
247
          body: {
248
            run_id: "run-9",
249
            output: {
250
              output: "resumed\n",
251
              offset: 10,
252
              next_offset: 4108,
253
              output_base_offset: 4100,
254
              truncated: true,
255
            },
256
          },
257
        });
258
      }
259
      return Effect.succeed({ status: 404, body: {} });
260
    });
261
262
    await Effect.runPromise(
263
      runCliWith([
264
        "box",
265
        "runs",
266
        "output",
267
        "bx_test123",
268
        "run-9",
269
        "--offset",
270
        "10",
271
      ]).pipe(Effect.provide(layer)),
272
    );
273
274
    const human = written[0]!.document.human;
275
    expect(human.some((line) => line.includes("EARLIER OUTPUT DROPPED"))).toBe(true);
276
    expect(human.some((line) => line.includes("resumed"))).toBe(true);
277
  });
278
279
  // Kept so a deployment still answering the older flat shape keeps working
280
  // while the fix rolls out.
281
  it("still renders a flat run output body", async () => {
282
    const { layer, written } = harness((req) => {
283
      if (req.path === "/api/v1/user") {
284
        return Effect.succeed({
285
          status: 200,
286
          body: { conversation_id: "conv-123" },
287
        });
288
      }
289
      if (req.path === "/api/v1/conversations/conv-123/boxes/bx_test123/runs/run-9/output") {
290
        return Effect.succeed({
291
          status: 200,
292
          body: { run_id: "run-9", output: "flat body output\n" },
293
        });
294
      }
295
      return Effect.succeed({ status: 404, body: {} });
296
    });
297
298
    await Effect.runPromise(
299
      runCliWith(["box", "runs", "output", "bx_test123", "run-9"]).pipe(Effect.provide(layer)),
300
    );
301
302
    const human = written[0]!.document.human;
303
    expect(human.some((line) => line.includes("flat body output"))).toBe(true);
304
  });
305
306
  // Production's `GET /api/v1/user` answers the forge identity and no
307
  // conversation, so the refusal has to point at the flag that works rather
308
  // than at an account state the caller cannot change.
309
  it("names --conversation when the deployment reports no conversation", async () => {
310
    const { layer } = harness((req) => {
311
      if (req.path === "/api/v1/user") {
312
        return Effect.succeed({
313
          status: 200,
314
          body: { id: 1, login: "operator", namespaces: [] },
315
        });
316
      }
317
      return Effect.succeed({ status: 404, body: {} });
318
    });
319
320
    // `Effect.flip` turns the refusal into the success value, so a run that
321
    // wrongly succeeded rejects the promise and fails the test.
322
    const failure = await Effect.runPromise(
323
      runCliWith(["box", "list"]).pipe(Effect.provide(layer), Effect.flip),
324
    );
325
326
    expect(JSON.stringify(failure)).toContain("--conversation");
327
  });
328
329
  it("skips the conversation probe when --conversation is given", async () => {
330
    const seen: Array<string> = [];
331
    const { layer, written } = harness((req) => {
332
      seen.push(req.path);
333
      if (req.path === "/api/v1/conversations/conv-explicit/boxes") {
334
        return Effect.succeed({ status: 200, body: { boxes: [] } });
335
      }
336
      return Effect.succeed({ status: 404, body: {} });
337
    });
338
339
    await Effect.runPromise(
340
      runCliWith(["box", "list", "--conversation", "conv-explicit"]).pipe(Effect.provide(layer)),
341
    );
342
343
    expect(seen).toEqual(["/api/v1/conversations/conv-explicit/boxes"]);
344
    expect(written.length).toBe(1);
345
  });
189 346
});

This page updates live while a promote is in flight · changelog