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");