From f85866c65bc41a07344aa9211e6ac60bb03da8a9 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:58:44 -0400 Subject: [PATCH] Show the failed step's own output when no output tail is recorded The server never fills conclusion.failure.detail.exec_output_tail, so the Output section never appeared. Fall back to the output the stopped stage recorded in the run state, skipping offloaded blob references and model request failures, whose step output is the agent's text. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 16 ++++++++++ .../app/routes/run-detail/failure.ts | 30 +++++++++++++------ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/fabro-web/app/routes/run-detail/failure.test.ts b/apps/fabro-web/app/routes/run-detail/failure.test.ts index 5beee580e..b24eefd87 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -139,6 +139,22 @@ describe("buildRunFailureView", () => { expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`)); }); + test("falls back to the output the failed step recorded", () => { + const runStages = stages(["test@1", "failed", "2026-10-10T18:05:42Z"]); + runStages["test@1"]!.output = "running 3 tests\ntest parse ... FAILED\n"; + const failure = view(failedRun({ message: "script exited with status 101" }, { runStages })); + expect(failure).toMatchObject({ + output: "running 3 tests\ntest parse ... FAILED", + nextStep: "Its output, under Details, usually says why.", + }); + + runStages["test@1"]!.output = "blob://sha256/abc"; + expect(view(failedRun({ message: "script exited with status 101" }, { runStages }))?.output).toBeNull(); + + runStages["test@1"]!.output = "I will now write the spec."; + expect(view(failedRun({ message: CREDIT }, { runStages }))?.output).toBeNull(); + }); + test("cuts a long fallback headline without splitting a character", () => { const headline = view(failedRun({ message: `${"🔥".repeat(200)} and more` }))!.headline; expect(Array.from(headline)).toHaveLength(111); diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 47587fe2d..7e3618f48 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -19,7 +19,10 @@ export interface RunFailureView { message: string; /** Provider id, when the failure is a model request the provider refused. */ provider: string | null; - /** Last OUTPUT_LINES of the failed command's stderr, else its stdout. */ + /** + * Last OUTPUT_LINES of the failed command's stderr, else its stdout, else + * the output the step the run stopped at recorded. + */ output: string | null; } @@ -48,14 +51,19 @@ export function buildRunFailureView( if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null; const { message, category } = failure.detail; - const output = outputTail(failure.detail.exec_output_tail); const request = parseModelRequest(message); + const stage = stoppedAt(state.stages); + // A refused model request's message is the whole story; the step's + // output would be the agent's own text. + const output = + outputTail(failure.detail.exec_output_tail) ?? + (request || !stage ? null : lastLines(state.stages[stage]?.output)); const name = (id: string) => providerNames.get(id) ?? id; const advice = reasonAdvice(failure.reason, category) ?? (request ? requestAdvice(request, message, name(request.provider)) : stepAdvice(message, category, output)); return { - stage: stoppedAt(state.stages), + stage: stage?.replace(/@1$/, "") ?? null, ...advice, message, provider: request?.provider ?? null, @@ -67,7 +75,7 @@ export function buildRunFailureView( function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView { const message = lifecycleError?.trim() || "The run ended without recording a reason."; return { - stage: stoppedAt(state.stages), + stage: stoppedAt(state.stages)?.replace(/@1$/, "") ?? null, headline: lifecycleError?.trim() ? firstSentence(message) : "The run's worker stopped before the run finished", nextStep: `Open the failed step's logs for details. To run it again, ${RETRY}.`, message, @@ -173,8 +181,8 @@ function stepAdvice(message: string, category: FailureCategory, output: string | } /** - * The last step to start, if it failed. An earlier failure that a later step - * recovered from isn't where the run stopped, so it isn't named. + * The key of the last step to start, if it failed. An earlier failure that a + * later step recovered from isn't where the run stopped, so it isn't named. */ function stoppedAt(stages: FailureState["stages"]): string | null { let last: [string, number] | null = null; @@ -183,12 +191,16 @@ function stoppedAt(stages: FailureState["stages"]): string | null { if (!Number.isNaN(started) && (!last || started > last[1])) last = [key, started]; } if (!last || stages[last[0]]?.state !== "failed") return null; - return last[0].replace(/@1$/, ""); + return last[0]; } function outputTail(tail: ExecOutputTail | null | undefined): string | null { - const stream = [tail?.stderr, tail?.stdout].find((text) => text?.trim()); - if (!stream) return null; + return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim())); +} + +function lastLines(stream: string | null | undefined): string | null { + // An offloaded output is a `blob://` reference, not text to show. + if (!stream?.trim() || stream.startsWith("blob://")) return null; // Carriage-return progress lines keep only what was drawn last. const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1)); return lines.slice(-OUTPUT_LINES).join("\n");