From 2664058fd7e716602671ac83ca7a13fe846b96d6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:57:25 +0000 Subject: [PATCH 1/5] feat(dashboard): show turn failure codes in the transcript Surface the stable privacy-safe failureCode on failed turns so the UI can name the failure type instead of one generic agent error. Co-Authored-By: David Cramer --- .../conversations/ConversationTranscript.tsx | 4 +- .../conversations/TranscriptFailureView.tsx | 20 +++++--- .../client/conversations/eventTranscript.ts | 4 +- .../conversations/transcriptBottomPinning.ts | 2 +- .../client/conversations/transcriptFailure.ts | 51 +++++++++++++++++++ .../conversations/transcriptRenderModel.ts | 10 ++-- .../client/conversations/transcriptSearch.tsx | 5 +- .../src/client/markdownExport.ts | 28 +++++----- packages/junior-dashboard/src/client/types.ts | 7 ++- .../src/mock-reporting/fixtures.ts | 2 +- .../tests/markdownExport.test.ts | 10 ++-- .../tests/telemetry-components.test.tsx | 10 ++-- .../tests/transcriptActivityGroup.test.ts | 2 +- .../tests/transcriptBottomPinning.test.ts | 2 +- .../tests/transcriptRenderModel.test.ts | 10 ++-- .../junior/src/api/conversations/events.ts | 3 +- .../junior/src/api/schema/conversation.ts | 8 ++- .../unit/api/conversation-events.test.ts | 14 +++-- 18 files changed, 130 insertions(+), 62 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/transcriptFailure.ts diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index 2d6184587e..0b305c2143 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -167,7 +167,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { )} renderFailure={(entry) => ( )} @@ -374,7 +374,7 @@ function RedactedTranscriptView(props: { )} renderFailure={(entry) => ( )} diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx index 691ff20406..2540868760 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx @@ -1,20 +1,23 @@ import { CircleAlert } from "lucide-react"; -import { getDashboardAgentName } from "../agentName"; import { formatMessageTimestamp } from "../format"; +import { + type TranscriptFailureCode, + transcriptFailureDescription, + transcriptFailureTitle, +} from "./transcriptFailure"; /** Render a terminal transcript failure as a distinct alert surface. */ export function TranscriptFailureView(props: { - outcome: "error" | "delivery_failed"; + failureCode: TranscriptFailureCode; timestamp?: number; }) { const timestamp = formatMessageTimestamp(props.timestamp); - const deliveryFailed = props.outcome === "delivery_failed"; return (
- {deliveryFailed ? "Message delivery failed" : "Agent response failed"} + {transcriptFailureTitle(props.failureCode)}
- {deliveryFailed - ? `${getDashboardAgentName()} could not deliver this message to its destination.` - : `The model response ended before ${getDashboardAgentName()} could complete this turn.`} + {transcriptFailureDescription(props.failureCode)} +
+
+ {props.failureCode}
{timestamp ? ( diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 058d7203f6..90f98f39ea 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -413,8 +413,8 @@ export function transcriptMessagesFromEvents( if (data.type === "turn_lifecycle" && data.state === "failed") { messages.push({ - role: data.failureKind === "delivery" ? "system" : "assistant", - outcome: data.failureKind === "delivery" ? "delivery_failed" : "error", + role: data.failureCode === "delivery_failed" ? "system" : "assistant", + failureCode: data.failureCode, parts: [], sourceSeq: event.seq, timestamp: eventTimestamp(event), diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 6b9539da25..ea9f854de3 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -171,7 +171,7 @@ function eventTailVersion(event: ConversationReportEvent | undefined): string { data.type, data.turnId, data.state, - "failureKind" in data ? data.failureKind : "", + "failureCode" in data ? data.failureCode : "", ].join(":"); case "structured_event": return [ diff --git a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts new file mode 100644 index 0000000000..f2a0ac0146 --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts @@ -0,0 +1,51 @@ +import { getDashboardAgentName } from "../agentName"; + +/** Stable privacy-safe failure codes shown in the conversation transcript. */ +export type TranscriptFailureCode = + | "agent_run_failed" + | "delivery_failed" + | "model_execution_failed" + | "persistence_failed"; + +/** Human title for one terminal transcript failure. */ +export function transcriptFailureTitle(failureCode: TranscriptFailureCode): string { + switch (failureCode) { + case "delivery_failed": + return "Message delivery failed"; + case "model_execution_failed": + return "Model execution failed"; + case "persistence_failed": + return "Persistence failed"; + case "agent_run_failed": + return "Agent run failed"; + } +} + +/** Human description for one terminal transcript failure. */ +export function transcriptFailureDescription( + failureCode: TranscriptFailureCode, +): string { + const agentName = getDashboardAgentName(); + switch (failureCode) { + case "delivery_failed": + return `${agentName} could not deliver this message to its destination.`; + case "model_execution_failed": + return `The model response ended before ${agentName} could complete this turn.`; + case "persistence_failed": + return `${agentName} could not persist the result of this turn.`; + case "agent_run_failed": + return `${agentName} hit an internal error while running this turn.`; + } +} + +/** Searchable plain text for one terminal transcript failure. */ +export function transcriptFailureSearchText( + failureCode: TranscriptFailureCode, +): string { + return [ + transcriptFailureTitle(failureCode), + transcriptFailureDescription(failureCode), + failureCode.replaceAll("_", " "), + failureCode, + ].join(" "); +} diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index 223467e6da..d8fd0782e0 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -12,7 +12,11 @@ import type { export type RenderedFailureEntry = { key: string; kind: "failure"; - outcome: "error" | "delivery_failed"; + failureCode: + | "agent_run_failed" + | "delivery_failed" + | "model_execution_failed" + | "persistence_failed"; timestamp?: number; }; @@ -157,11 +161,11 @@ export function groupTranscriptMessages( } flushMessage(); - if (message.outcome) { + if (message.failureCode) { entries.push({ key: `${message.sourceSeq}:failure`, kind: "failure", - outcome: message.outcome, + failureCode: message.failureCode, timestamp: message.timestamp, }); } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx index a73236f883..28af9774b9 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx +++ b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx @@ -5,6 +5,7 @@ import { messageRawText, type RenderedTranscriptEntry, } from "./transcriptRenderModel"; +import { transcriptFailureSearchText } from "./transcriptFailure"; import { stringifyPartValue } from "../format"; // ─── Context ──────────────────────────────────────────────────────────────── @@ -131,9 +132,7 @@ export function entryMatchesSearch( if (entry.kind === "failure") { return textContains( - entry.outcome === "delivery_failed" - ? "message delivery failed" - : "agent response failed error", + transcriptFailureSearchText(entry.failureCode), normalizedQuery, ); } diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index 2224f1e44c..7430a535a7 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -14,8 +14,11 @@ import { groupTranscriptMessages, messageRawText, } from "./conversations/transcriptRenderModel"; -import { getDashboardAgentName } from "./agentName"; import { conversationTranscriptMessages } from "./conversations/eventTranscript"; +import { + transcriptFailureDescription, + transcriptFailureTitle, +} from "./conversations/transcriptFailure"; import type { Conversation, ConversationTranscript, @@ -126,7 +129,7 @@ function appendTranscriptMessages( appendFailure( lines, conversationTranscript, - entry.outcome, + entry.failureCode, entry.timestamp, ); continue; @@ -213,22 +216,17 @@ function appendReasoning( function appendFailure( lines: string[], conversationTranscript: ConversationTranscript, - outcome: "error" | "delivery_failed", + failureCode: + | "agent_run_failed" + | "delivery_failed" + | "model_execution_failed" + | "persistence_failed", timestamp: number | undefined, ): void { - lines.push( - "", - outcome === "delivery_failed" - ? "### Message delivery failed" - : "### Agent response failed", - ); + lines.push("", `### ${transcriptFailureTitle(failureCode)}`); addEventMeta(lines, conversationTranscript, timestamp); - lines.push( - "", - outcome === "delivery_failed" - ? `${getDashboardAgentName()} could not deliver this message to its destination.` - : `The model response ended before ${getDashboardAgentName()} could complete this turn.`, - ); + lines.push("", transcriptFailureDescription(failureCode)); + addMetaLine(lines, "Failure code", failureCode); } function appendContextEvent( diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 5f1c31a209..17294421bc 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -128,7 +128,12 @@ export type TranscriptViewMessage = { reasoningLevel: string; source: "configured" | "inherited" | "router"; }; - outcome?: "error" | "delivery_failed"; + /** Stable privacy-safe failure code for a terminal failed turn. */ + failureCode?: + | "agent_run_failed" + | "delivery_failed" + | "model_execution_failed" + | "persistence_failed"; parts: TranscriptViewPart[]; role: "assistant" | "system" | "tool" | "user"; source?: "slack" | "web"; diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index cc98401706..e3afef7487 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -1185,7 +1185,7 @@ function failedConversation(nowMs: number): ConversationDetailReport { type: "turn_lifecycle", turnId: "failed-turn", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }), ], }); diff --git a/packages/junior-dashboard/tests/markdownExport.test.ts b/packages/junior-dashboard/tests/markdownExport.test.ts index c933d9ea96..6e0d941e9b 100644 --- a/packages/junior-dashboard/tests/markdownExport.test.ts +++ b/packages/junior-dashboard/tests/markdownExport.test.ts @@ -268,7 +268,7 @@ describe("dashboard canonical-event Markdown export", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }), ]), ); @@ -292,7 +292,8 @@ describe("dashboard canonical-event Markdown export", () => { expect(markdown).toContain("- Model: openai/gpt-5-mini"); expect(markdown).toContain("- Reasoning: medium"); expect(markdown).toContain("Investigate the remaining deployment failure."); - expect(markdown).toContain("### Agent response failed"); + expect(markdown).toContain("### Model execution failed"); + expect(markdown).toContain("model_execution_failed"); expect(markdown).not.toContain("missing"); expect(markdown).not.toContain("Result: running"); }); @@ -304,7 +305,7 @@ describe("dashboard canonical-event Markdown export", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "delivery", + failureCode: "delivery_failed", }), ]), ); @@ -314,7 +315,8 @@ describe("dashboard canonical-event Markdown export", () => { "Junior could not deliver this message to its destination.", ); expect(markdown).not.toContain("turn-1"); - expect(markdown).not.toContain("Agent response failed"); + expect(markdown).not.toContain("Model execution failed"); + expect(markdown).not.toContain("Agent run failed"); }); it("labels redacted in-progress tools without inventing a completion", () => { diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index ca08fc7bec..5bd85a8684 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -688,13 +688,14 @@ describe("dashboard canonical-event components", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }), ]), ); expect(html).toContain("Context compacted"); expect(html).toContain("Model handoff"); - expect(html).toContain("Agent response failed"); + expect(html).toContain("Model execution failed"); + expect(html).toContain("model_execution_failed"); }); it("anchors structured events to the transcript rail", () => { @@ -809,7 +810,7 @@ describe("dashboard canonical-event components", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "delivery", + failureCode: "delivery_failed", }), ]), ); @@ -817,7 +818,8 @@ describe("dashboard canonical-event components", () => { expect(html).toContain( "Junior could not deliver this message to its destination.", ); - expect(html).not.toContain("Agent response failed"); + expect(html).not.toContain("Model execution failed"); + expect(html).not.toContain("Agent run failed"); }); it("does not invent an object for an empty raw message", () => { diff --git a/packages/junior-dashboard/tests/transcriptActivityGroup.test.ts b/packages/junior-dashboard/tests/transcriptActivityGroup.test.ts index a69575ead1..08a9fc322d 100644 --- a/packages/junior-dashboard/tests/transcriptActivityGroup.test.ts +++ b/packages/junior-dashboard/tests/transcriptActivityGroup.test.ts @@ -108,7 +108,7 @@ function failure(): RenderedTranscriptEntry { return { key: "failure:1", kind: "failure", - outcome: "error", + failureCode: "model_execution_failed", timestamp: 1, }; } diff --git a/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts b/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts index 8212072b75..9bccad023a 100644 --- a/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts +++ b/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts @@ -327,7 +327,7 @@ describe("transcript bottom pinning", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }, }, ], diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index 4865f510f9..eb12408d1a 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -626,7 +626,7 @@ describe("canonical event transcript reduction", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }), ]), ); @@ -860,13 +860,13 @@ describe("canonical event transcript reduction", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }), event(7, "2026-01-01T00:00:07.000Z", { type: "turn_lifecycle", turnId: "turn-2", state: "failed", - failureKind: "delivery", + failureCode: "delivery_failed", }), ]), ), @@ -902,7 +902,7 @@ describe("transcript render grouping", () => { const messages: TranscriptViewMessage[] = [ { role: "assistant", - outcome: "error", + failureCode: "model_execution_failed", sourceSeq: 42, timestamp: 1_000, parts: [], @@ -912,7 +912,7 @@ describe("transcript render grouping", () => { { key: "42:failure", kind: "failure", - outcome: "error", + failureCode: "model_execution_failed", timestamp: 1_000, }, ]); diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index a9a3d30a7e..3674f6f41d 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -401,8 +401,7 @@ function reportEventData(args: { type: "turn_lifecycle", turnId: data.turnId, state: "failed", - failureKind: - data.failureCode === "delivery_failed" ? "delivery" : "agent", + failureCode: data.failureCode, }; case "compaction": return { diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 4bfff922ed..6d85ba2df3 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -423,7 +423,13 @@ const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( type: z.literal("turn_lifecycle"), turnId: z.string().min(1), state: z.literal("failed"), - failureKind: z.enum(["agent", "delivery"]), + // Stable privacy-safe failure codes from conversation history. + failureCode: z.enum([ + "agent_run_failed", + "delivery_failed", + "model_execution_failed", + "persistence_failed", + ]), }) .strict(), ], diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 0bb369d3ac..8b1332599a 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -890,13 +890,13 @@ describe("conversation report event projection", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }); expect(projected[4]?.data).toEqual({ type: "turn_lifecycle", turnId: "turn-delivery-1", state: "failed", - failureKind: "delivery", + failureCode: "delivery_failed", }); const serialized = JSON.stringify(projected); for (const forbidden of [ @@ -909,13 +909,11 @@ describe("conversation report event projection", () => { "private-authorization-id", "private tool result", "private provider error", - "model_execution_failed", eventId, "private-provider", "actorId", "authorizationId", "eventId", - "failureCode", "args", "content", "meta", @@ -1152,7 +1150,7 @@ describe("conversation report event projection", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "delivery", + failureCode: "delivery_failed", }, { type: "tool_calls", @@ -1216,7 +1214,7 @@ describe("conversation report event projection", () => { type: "turn_lifecycle", turnId: "turn-1", state: "failed", - failureKind: "agent", + failureCode: "model_execution_failed", }, }; @@ -1234,7 +1232,7 @@ describe("conversation report event projection", () => { type: "turn_lifecycle", turnId: "turn-1", state: "succeeded", - failureKind: "agent", + failureCode: "model_execution_failed", }, }).success, ).toBe(false); @@ -1253,7 +1251,7 @@ describe("conversation report event projection", () => { expect( conversationReportEventSchema.safeParse({ ...valid, - data: { ...valid.data, failureCode: "private-failure-code" }, + data: { ...valid.data, failureCode: "not_a_real_code" }, }).success, ).toBe(false); expect( From f9cd645e80afdf516e535e7675fa0e3a916d20dd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:10:38 +0000 Subject: [PATCH 2/5] refactor(dashboard): share failure codes and simplify failure copy Use one report-schema failure code type across the API and transcript UI, and keep failure labels plain. Co-Authored-By: David Cramer --- .../conversations/TranscriptFailureView.tsx | 4 +-- .../client/conversations/transcriptFailure.ts | 33 +++++++++---------- .../conversations/transcriptRenderModel.ts | 8 ++--- .../src/client/markdownExport.ts | 7 ++-- packages/junior-dashboard/src/client/types.ts | 8 ++--- .../tests/markdownExport.test.ts | 4 +-- .../tests/telemetry-components.test.tsx | 4 +-- packages/junior/src/api/schema.ts | 2 ++ .../junior/src/api/schema/conversation.ts | 19 +++++++---- 9 files changed, 40 insertions(+), 49 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx index 2540868760..a78ea1b908 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx @@ -1,15 +1,15 @@ import { CircleAlert } from "lucide-react"; +import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; import { formatMessageTimestamp } from "../format"; import { - type TranscriptFailureCode, transcriptFailureDescription, transcriptFailureTitle, } from "./transcriptFailure"; /** Render a terminal transcript failure as a distinct alert surface. */ export function TranscriptFailureView(props: { - failureCode: TranscriptFailureCode; + failureCode: ConversationTurnFailureCode; timestamp?: number; }) { const timestamp = formatMessageTimestamp(props.timestamp); diff --git a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts index f2a0ac0146..203f45a247 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts @@ -1,46 +1,43 @@ -import { getDashboardAgentName } from "../agentName"; +import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; -/** Stable privacy-safe failure codes shown in the conversation transcript. */ -export type TranscriptFailureCode = - | "agent_run_failed" - | "delivery_failed" - | "model_execution_failed" - | "persistence_failed"; +import { getDashboardAgentName } from "../agentName"; -/** Human title for one terminal transcript failure. */ -export function transcriptFailureTitle(failureCode: TranscriptFailureCode): string { +/** Title for one failed turn in the transcript. */ +export function transcriptFailureTitle( + failureCode: ConversationTurnFailureCode, +): string { switch (failureCode) { case "delivery_failed": return "Message delivery failed"; case "model_execution_failed": return "Model execution failed"; case "persistence_failed": - return "Persistence failed"; + return "Save failed"; case "agent_run_failed": return "Agent run failed"; } } -/** Human description for one terminal transcript failure. */ +/** Short description for one failed turn in the transcript. */ export function transcriptFailureDescription( - failureCode: TranscriptFailureCode, + failureCode: ConversationTurnFailureCode, ): string { const agentName = getDashboardAgentName(); switch (failureCode) { case "delivery_failed": - return `${agentName} could not deliver this message to its destination.`; + return `${agentName} could not deliver this message.`; case "model_execution_failed": - return `The model response ended before ${agentName} could complete this turn.`; + return `The model stopped before ${agentName} finished this turn.`; case "persistence_failed": - return `${agentName} could not persist the result of this turn.`; + return `${agentName} could not save the result of this turn.`; case "agent_run_failed": - return `${agentName} hit an internal error while running this turn.`; + return `${agentName} hit an internal error during this turn.`; } } -/** Searchable plain text for one terminal transcript failure. */ +/** Search text for one failed turn in the transcript. */ export function transcriptFailureSearchText( - failureCode: TranscriptFailureCode, + failureCode: ConversationTurnFailureCode, ): string { return [ transcriptFailureTitle(failureCode), diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index d8fd0782e0..36967d7a21 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -1,3 +1,5 @@ +import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; + import type { TranscriptViewAttachmentsDeliveredPart, TranscriptViewContextEventPart, @@ -12,11 +14,7 @@ import type { export type RenderedFailureEntry = { key: string; kind: "failure"; - failureCode: - | "agent_run_failed" - | "delivery_failed" - | "model_execution_failed" - | "persistence_failed"; + failureCode: ConversationTurnFailureCode; timestamp?: number; }; diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index 7430a535a7..ebb9aa2170 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -19,6 +19,7 @@ import { transcriptFailureDescription, transcriptFailureTitle, } from "./conversations/transcriptFailure"; +import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; import type { Conversation, ConversationTranscript, @@ -216,11 +217,7 @@ function appendReasoning( function appendFailure( lines: string[], conversationTranscript: ConversationTranscript, - failureCode: - | "agent_run_failed" - | "delivery_failed" - | "model_execution_failed" - | "persistence_failed", + failureCode: ConversationTurnFailureCode, timestamp: number | undefined, ): void { lines.push("", `### ${transcriptFailureTitle(failureCode)}`); diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 17294421bc..b19b773aa5 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -8,6 +8,7 @@ import type { ActorIdentity, ConversationStatsReport, ConversationSummaryReport, + ConversationTurnFailureCode, } from "@sentry/junior/api/schema"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; import type { ConversationEventPresentation } from "@sentry/junior-plugin-api"; @@ -128,12 +129,7 @@ export type TranscriptViewMessage = { reasoningLevel: string; source: "configured" | "inherited" | "router"; }; - /** Stable privacy-safe failure code for a terminal failed turn. */ - failureCode?: - | "agent_run_failed" - | "delivery_failed" - | "model_execution_failed" - | "persistence_failed"; + failureCode?: ConversationTurnFailureCode; parts: TranscriptViewPart[]; role: "assistant" | "system" | "tool" | "user"; source?: "slack" | "web"; diff --git a/packages/junior-dashboard/tests/markdownExport.test.ts b/packages/junior-dashboard/tests/markdownExport.test.ts index 6e0d941e9b..6f370176c7 100644 --- a/packages/junior-dashboard/tests/markdownExport.test.ts +++ b/packages/junior-dashboard/tests/markdownExport.test.ts @@ -311,9 +311,7 @@ describe("dashboard canonical-event Markdown export", () => { ); expect(markdown).toContain("### Message delivery failed"); - expect(markdown).toContain( - "Junior could not deliver this message to its destination.", - ); + expect(markdown).toContain("Junior could not deliver this message."); expect(markdown).not.toContain("turn-1"); expect(markdown).not.toContain("Model execution failed"); expect(markdown).not.toContain("Agent run failed"); diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 5bd85a8684..d483c6ce1e 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -815,9 +815,7 @@ describe("dashboard canonical-event components", () => { ]), ); expect(html).toContain("Message delivery failed"); - expect(html).toContain( - "Junior could not deliver this message to its destination.", - ); + expect(html).toContain("Junior could not deliver this message."); expect(html).not.toContain("Model execution failed"); expect(html).not.toContain("Agent run failed"); }); diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 9a25aa20b3..009734aa8e 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -36,6 +36,7 @@ export { conversationPendingMessagesReportSchema, conversationReportEventDataSchema, conversationReportEventSchema, + conversationTurnFailureCodeSchema, conversationStatsReportSchema, conversationSummaryReportSchema, createConversationBodySchema, @@ -60,6 +61,7 @@ export type { ConversationReportEvent, ConversationReportEventData, ConversationReportStatus, + ConversationTurnFailureCode, ConversationMetricDay, ConversationModelUsage, ConversationParams, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 6d85ba2df3..d179a67ac5 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -400,6 +400,14 @@ const conversationReportAssistantMessageEventDataSchema = z }) .strict(); +/** Stable failure codes exposed on failed turn lifecycle report events. */ +export const conversationTurnFailureCodeSchema = z.enum([ + "agent_run_failed", + "delivery_failed", + "model_execution_failed", + "persistence_failed", +]); + const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( "state", [ @@ -423,13 +431,7 @@ const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( type: z.literal("turn_lifecycle"), turnId: z.string().min(1), state: z.literal("failed"), - // Stable privacy-safe failure codes from conversation history. - failureCode: z.enum([ - "agent_run_failed", - "delivery_failed", - "model_execution_failed", - "persistence_failed", - ]), + failureCode: conversationTurnFailureCodeSchema, }) .strict(), ], @@ -797,6 +799,9 @@ export const conversationStatsReportSchema = z export type ConversationReportStatus = z.infer< typeof conversationReportStatusSchema >; +export type ConversationTurnFailureCode = z.infer< + typeof conversationTurnFailureCodeSchema +>; export type ConversationSurface = z.infer; export type ConversationCost = z.infer; export type ConversationUsage = z.infer; From dcfd17dae34be500d79a8b1fed1861cf8702d89c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:39:28 +0000 Subject: [PATCH 3/5] feat(chat): persist and show turn failure reasons Store a privacy-safe failureReason on failed turns and render the specific model/provider or execution reason in the transcript. Co-Authored-By: David Cramer --- .../conversations/ConversationTranscript.tsx | 2 + .../conversations/TranscriptFailureView.tsx | 14 ++- .../client/conversations/eventTranscript.ts | 3 + .../conversations/transcriptBottomPinning.ts | 1 + .../client/conversations/transcriptFailure.ts | 88 ++++++++++++++++++- .../conversations/transcriptRenderModel.ts | 9 +- .../client/conversations/transcriptSearch.tsx | 2 +- .../src/client/markdownExport.ts | 17 +++- packages/junior-dashboard/src/client/types.ts | 2 + .../src/mock-reporting/fixtures.ts | 1 + .../tests/markdownExport.test.ts | 8 +- .../tests/telemetry-components.test.tsx | 9 +- .../junior/src/api/conversations/events.ts | 3 + packages/junior/src/api/schema.ts | 2 + .../junior/src/api/schema/conversation.ts | 23 +++++ packages/junior/src/chat/api-turns/work.ts | 4 + .../junior/src/chat/conversations/history.ts | 28 ++++++ .../src/chat/conversations/turn-lifecycle.ts | 5 ++ packages/junior/src/chat/local/runner.ts | 5 ++ .../junior/src/chat/providers/slack/resume.ts | 3 + .../junior/src/chat/providers/slack/turn.ts | 5 ++ .../junior/src/chat/runtime/turn-execution.ts | 4 + .../chat/services/turn-failure-response.ts | 32 +++++++ .../unit/api/conversation-events.test.ts | 2 + .../services/turn-failure-response.test.ts | 27 +++++- 25 files changed, 277 insertions(+), 22 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index 0b305c2143..44e033afc5 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -168,6 +168,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { renderFailure={(entry) => ( )} @@ -375,6 +376,7 @@ function RedactedTranscriptView(props: { renderFailure={(entry) => ( )} diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx index a78ea1b908..02ea516a43 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx @@ -1,5 +1,8 @@ import { CircleAlert } from "lucide-react"; -import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; +import type { + ConversationTurnFailureCode, + ConversationTurnFailureReason, +} from "@sentry/junior/api/schema"; import { formatMessageTimestamp } from "../format"; import { @@ -10,14 +13,17 @@ import { /** Render a terminal transcript failure as a distinct alert surface. */ export function TranscriptFailureView(props: { failureCode: ConversationTurnFailureCode; + failureReason?: ConversationTurnFailureReason; timestamp?: number; }) { const timestamp = formatMessageTimestamp(props.timestamp); + const detail = props.failureReason ?? props.failureCode; return (
- {transcriptFailureTitle(props.failureCode)} + {transcriptFailureTitle(props.failureCode, props.failureReason)}
- {transcriptFailureDescription(props.failureCode)} + {transcriptFailureDescription(props.failureCode, props.failureReason)}
- {props.failureCode} + {detail}
{timestamp ? ( diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 90f98f39ea..8ec8b851c2 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -415,6 +415,9 @@ export function transcriptMessagesFromEvents( messages.push({ role: data.failureCode === "delivery_failed" ? "system" : "assistant", failureCode: data.failureCode, + ...(data.failureReason + ? { failureReason: data.failureReason } + : undefined), parts: [], sourceSeq: event.seq, timestamp: eventTimestamp(event), diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index ea9f854de3..2534174cb1 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -172,6 +172,7 @@ function eventTailVersion(event: ConversationReportEvent | undefined): string { data.turnId, data.state, "failureCode" in data ? data.failureCode : "", + "failureReason" in data ? (data.failureReason ?? "") : "", ].join(":"); case "structured_event": return [ diff --git a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts index 203f45a247..b914e6fbd9 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts @@ -1,11 +1,50 @@ -import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; +import type { + ConversationTurnFailureCode, + ConversationTurnFailureReason, +} from "@sentry/junior/api/schema"; import { getDashboardAgentName } from "../agentName"; /** Title for one failed turn in the transcript. */ export function transcriptFailureTitle( failureCode: ConversationTurnFailureCode, + failureReason?: ConversationTurnFailureReason, ): string { + if (failureCode === "model_execution_failed" && failureReason) { + switch (failureReason) { + case "auth": + return "Model auth failed"; + case "permission": + return "Model access denied"; + case "rate_limit": + return "Model rate limited"; + case "capacity": + return "Model at capacity"; + case "timeout": + return "Model timed out"; + case "network": + return "Model network error"; + case "server": + return "Model provider error"; + case "invalid_request": + return "Invalid model request"; + case "invalid_response": + return "Invalid model response"; + case "quota": + return "Model quota exhausted"; + case "content_policy": + return "Blocked by content policy"; + case "empty_output": + return "Empty model response"; + case "tool_errors": + return "Tool errors stopped the turn"; + case "suppressed_output": + return "Model output was dropped"; + case "unknown": + return "Model provider error"; + } + } + switch (failureCode) { case "delivery_failed": return "Message delivery failed"; @@ -14,15 +53,52 @@ export function transcriptFailureTitle( case "persistence_failed": return "Save failed"; case "agent_run_failed": - return "Agent run failed"; + return "Internal error"; } } /** Short description for one failed turn in the transcript. */ export function transcriptFailureDescription( failureCode: ConversationTurnFailureCode, + failureReason?: ConversationTurnFailureReason, ): string { const agentName = getDashboardAgentName(); + + if (failureCode === "model_execution_failed" && failureReason) { + switch (failureReason) { + case "auth": + return "The model provider rejected credentials. An admin needs to fix configuration."; + case "permission": + return "The model provider denied access to this model or request."; + case "rate_limit": + return "The model is rate-limited. Try again shortly."; + case "capacity": + return "The selected model is at capacity. Try again shortly."; + case "timeout": + return "The model provider timed out before the turn finished."; + case "network": + return "The model provider had a network problem before the turn finished."; + case "server": + return "The model provider returned a server error."; + case "invalid_request": + return "The model provider rejected this request as invalid."; + case "invalid_response": + return "The model provider returned an invalid response."; + case "quota": + return "The model provider quota is exhausted. An admin needs to fix billing or limits."; + case "content_policy": + return "The model provider blocked this request under its content policy."; + case "empty_output": + return `The model returned no usable text before ${agentName} finished this turn.`; + case "tool_errors": + return "One or more tools failed and the turn could not finish."; + case "suppressed_output": + return `The model produced text that ${agentName} could not deliver.`; + case "unknown": + return "The model provider failed for an unknown reason."; + } + } + switch (failureCode) { case "delivery_failed": return `${agentName} could not deliver this message.`; @@ -38,11 +114,15 @@ export function transcriptFailureDescription( /** Search text for one failed turn in the transcript. */ export function transcriptFailureSearchText( failureCode: ConversationTurnFailureCode, + failureReason?: ConversationTurnFailureReason, ): string { return [ - transcriptFailureTitle(failureCode), - transcriptFailureDescription(failureCode), + transcriptFailureTitle(failureCode, failureReason), + transcriptFailureDescription(failureCode, failureReason), failureCode.replaceAll("_", " "), failureCode, + ...(failureReason + ? [failureReason.replaceAll("_", " "), failureReason] + : []), ].join(" "); } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index 36967d7a21..d0f43fdf80 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -1,4 +1,7 @@ -import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; +import type { + ConversationTurnFailureCode, + ConversationTurnFailureReason, +} from "@sentry/junior/api/schema"; import type { TranscriptViewAttachmentsDeliveredPart, @@ -15,6 +18,7 @@ export type RenderedFailureEntry = { key: string; kind: "failure"; failureCode: ConversationTurnFailureCode; + failureReason?: ConversationTurnFailureReason; timestamp?: number; }; @@ -164,6 +168,9 @@ export function groupTranscriptMessages( key: `${message.sourceSeq}:failure`, kind: "failure", failureCode: message.failureCode, + ...(message.failureReason + ? { failureReason: message.failureReason } + : undefined), timestamp: message.timestamp, }); } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx index 28af9774b9..32e135ef33 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx +++ b/packages/junior-dashboard/src/client/conversations/transcriptSearch.tsx @@ -132,7 +132,7 @@ export function entryMatchesSearch( if (entry.kind === "failure") { return textContains( - transcriptFailureSearchText(entry.failureCode), + transcriptFailureSearchText(entry.failureCode, entry.failureReason), normalizedQuery, ); } diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index ebb9aa2170..de6fb7df4f 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -19,7 +19,10 @@ import { transcriptFailureDescription, transcriptFailureTitle, } from "./conversations/transcriptFailure"; -import type { ConversationTurnFailureCode } from "@sentry/junior/api/schema"; +import type { + ConversationTurnFailureCode, + ConversationTurnFailureReason, +} from "@sentry/junior/api/schema"; import type { Conversation, ConversationTranscript, @@ -131,6 +134,7 @@ function appendTranscriptMessages( lines, conversationTranscript, entry.failureCode, + entry.failureReason, entry.timestamp, ); continue; @@ -218,12 +222,19 @@ function appendFailure( lines: string[], conversationTranscript: ConversationTranscript, failureCode: ConversationTurnFailureCode, + failureReason: ConversationTurnFailureReason | undefined, timestamp: number | undefined, ): void { - lines.push("", `### ${transcriptFailureTitle(failureCode)}`); + lines.push( + "", + `### ${transcriptFailureTitle(failureCode, failureReason)}`, + ); addEventMeta(lines, conversationTranscript, timestamp); - lines.push("", transcriptFailureDescription(failureCode)); + lines.push("", transcriptFailureDescription(failureCode, failureReason)); addMetaLine(lines, "Failure code", failureCode); + if (failureReason) { + addMetaLine(lines, "Failure reason", failureReason); + } } function appendContextEvent( diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index b19b773aa5..23937e0f4d 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -9,6 +9,7 @@ import type { ConversationStatsReport, ConversationSummaryReport, ConversationTurnFailureCode, + ConversationTurnFailureReason, } from "@sentry/junior/api/schema"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; import type { ConversationEventPresentation } from "@sentry/junior-plugin-api"; @@ -130,6 +131,7 @@ export type TranscriptViewMessage = { source: "configured" | "inherited" | "router"; }; failureCode?: ConversationTurnFailureCode; + failureReason?: ConversationTurnFailureReason; parts: TranscriptViewPart[]; role: "assistant" | "system" | "tool" | "user"; source?: "slack" | "web"; diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index e3afef7487..1c47e3f12d 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -1186,6 +1186,7 @@ function failedConversation(nowMs: number): ConversationDetailReport { turnId: "failed-turn", state: "failed", failureCode: "model_execution_failed", + failureReason: "network", }), ], }); diff --git a/packages/junior-dashboard/tests/markdownExport.test.ts b/packages/junior-dashboard/tests/markdownExport.test.ts index 6f370176c7..79c36a8b60 100644 --- a/packages/junior-dashboard/tests/markdownExport.test.ts +++ b/packages/junior-dashboard/tests/markdownExport.test.ts @@ -269,6 +269,7 @@ describe("dashboard canonical-event Markdown export", () => { turnId: "turn-1", state: "failed", failureCode: "model_execution_failed", + failureReason: "network", }), ]), ); @@ -292,8 +293,9 @@ describe("dashboard canonical-event Markdown export", () => { expect(markdown).toContain("- Model: openai/gpt-5-mini"); expect(markdown).toContain("- Reasoning: medium"); expect(markdown).toContain("Investigate the remaining deployment failure."); - expect(markdown).toContain("### Model execution failed"); + expect(markdown).toContain("### Model network error"); expect(markdown).toContain("model_execution_failed"); + expect(markdown).toContain("network"); expect(markdown).not.toContain("missing"); expect(markdown).not.toContain("Result: running"); }); @@ -313,8 +315,8 @@ describe("dashboard canonical-event Markdown export", () => { expect(markdown).toContain("### Message delivery failed"); expect(markdown).toContain("Junior could not deliver this message."); expect(markdown).not.toContain("turn-1"); - expect(markdown).not.toContain("Model execution failed"); - expect(markdown).not.toContain("Agent run failed"); + expect(markdown).not.toContain("Model network error"); + expect(markdown).not.toContain("Internal error"); }); it("labels redacted in-progress tools without inventing a completion", () => { diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index d483c6ce1e..a08d84ddd1 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -689,13 +689,14 @@ describe("dashboard canonical-event components", () => { turnId: "turn-1", state: "failed", failureCode: "model_execution_failed", + failureReason: "network", }), ]), ); expect(html).toContain("Context compacted"); expect(html).toContain("Model handoff"); - expect(html).toContain("Model execution failed"); - expect(html).toContain("model_execution_failed"); + expect(html).toContain("Model network error"); + expect(html).toContain("network"); }); it("anchors structured events to the transcript rail", () => { @@ -816,8 +817,8 @@ describe("dashboard canonical-event components", () => { ); expect(html).toContain("Message delivery failed"); expect(html).toContain("Junior could not deliver this message."); - expect(html).not.toContain("Model execution failed"); - expect(html).not.toContain("Agent run failed"); + expect(html).not.toContain("Model network error"); + expect(html).not.toContain("Internal error"); }); it("does not invent an object for an empty raw message", () => { diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 3674f6f41d..95b7230e39 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -402,6 +402,9 @@ function reportEventData(args: { turnId: data.turnId, state: "failed", failureCode: data.failureCode, + ...(data.failureReason + ? { failureReason: data.failureReason } + : undefined), }; case "compaction": return { diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 009734aa8e..74d5ff78ef 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -37,6 +37,7 @@ export { conversationReportEventDataSchema, conversationReportEventSchema, conversationTurnFailureCodeSchema, + conversationTurnFailureReasonSchema, conversationStatsReportSchema, conversationSummaryReportSchema, createConversationBodySchema, @@ -62,6 +63,7 @@ export type { ConversationReportEventData, ConversationReportStatus, ConversationTurnFailureCode, + ConversationTurnFailureReason, ConversationMetricDay, ConversationModelUsage, ConversationParams, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index d179a67ac5..4cd8b37d66 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -408,6 +408,25 @@ export const conversationTurnFailureCodeSchema = z.enum([ "persistence_failed", ]); +/** Stable failure reasons exposed on failed turn lifecycle report events. */ +export const conversationTurnFailureReasonSchema = z.enum([ + "auth", + "permission", + "rate_limit", + "capacity", + "timeout", + "network", + "server", + "invalid_request", + "invalid_response", + "quota", + "content_policy", + "unknown", + "empty_output", + "tool_errors", + "suppressed_output", +]); + const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( "state", [ @@ -432,6 +451,7 @@ const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( turnId: z.string().min(1), state: z.literal("failed"), failureCode: conversationTurnFailureCodeSchema, + failureReason: conversationTurnFailureReasonSchema.optional(), }) .strict(), ], @@ -802,6 +822,9 @@ export type ConversationReportStatus = z.infer< export type ConversationTurnFailureCode = z.infer< typeof conversationTurnFailureCodeSchema >; +export type ConversationTurnFailureReason = z.infer< + typeof conversationTurnFailureReasonSchema +>; export type ConversationSurface = z.infer; export type ConversationCost = z.infer; export type ConversationUsage = z.infer; diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index 6a974193c8..3f45993f0d 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -787,6 +787,7 @@ export function createApiTurnWorker( reply: result, logException, }); + const modelFailureReason = finalized.failureReason; const reply = finalized.reply; modelFailureEventId = finalized.eventId; if (reply.diagnostics.outcome !== "success") { @@ -836,6 +837,9 @@ export function createApiTurnWorker( ? { eventId: modelFailureEventId } : undefined), failureCode: "model_execution_failed", + ...(modelFailureReason + ? { failureReason: modelFailureReason } + : undefined), outcome: "failed", }; }, diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index dbb53f3760..871878f77e 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -249,6 +249,33 @@ export type ConversationTurnFailureCode = z.output< typeof conversationTurnFailureCodeSchema >; +/** + * Stable reason for a failed turn. + * Provider kinds stay privacy-safe; execution classes avoid raw exception text. + */ +export const conversationTurnFailureReasonSchema = z.enum([ + "auth", + "permission", + "rate_limit", + "capacity", + "timeout", + "network", + "server", + "invalid_request", + "invalid_response", + "quota", + "content_policy", + "unknown", + "empty_output", + "tool_errors", + "suppressed_output", +]); + +/** Privacy-safe reason persisted on a failed turn. */ +export type ConversationTurnFailureReason = z.output< + typeof conversationTurnFailureReasonSchema +>; + const turnStartedEventDataSchema = z .object({ type: z.literal("turn_started"), @@ -331,6 +358,7 @@ const turnFailedEventDataSchema = z type: z.literal("turn_failed"), turnId: z.string().min(1), failureCode: conversationTurnFailureCodeSchema, + failureReason: conversationTurnFailureReasonSchema.optional(), eventId: z .string() .regex(/^[a-f0-9]{32}$/i) diff --git a/packages/junior/src/chat/conversations/turn-lifecycle.ts b/packages/junior/src/chat/conversations/turn-lifecycle.ts index 66b2ff6a61..18c5278f24 100644 --- a/packages/junior/src/chat/conversations/turn-lifecycle.ts +++ b/packages/junior/src/chat/conversations/turn-lifecycle.ts @@ -1,6 +1,7 @@ import type { ConversationEventStore, ConversationTurnFailureCode, + ConversationTurnFailureReason, } from "./history"; /** Product-owned inputs for opening one correlated conversation turn. */ @@ -26,6 +27,7 @@ export interface FailConversationTurnInput { createdAtMs: number; eventId?: string; failureCode: ConversationTurnFailureCode; + failureReason?: ConversationTurnFailureReason; turnId: string; } @@ -81,6 +83,9 @@ export class ConversationTurnLifecycleService implements ConversationTurnLifecyc type: "turn_failed", turnId: input.turnId, failureCode: input.failureCode, + ...(input.failureReason + ? { failureReason: input.failureReason } + : undefined), ...(input.eventId ? { eventId: input.eventId } : undefined), }, }, diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index a44b89a45c..6a4b692a98 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -245,6 +245,9 @@ async function runLocalAgentTurnInContext( let completedState: ReturnType; let failureCode: ConversationTurnFailureCode = "persistence_failed"; let modelFailureEventId: string | undefined; + let modelFailureReason: + | ReturnType["failureReason"] + | undefined; let modelFailureCaptureAttempted = false; let currentRunId: string | undefined; let completionSliceId = 1; @@ -422,6 +425,7 @@ async function runLocalAgentTurnInContext( }); reply = finalized.reply; modelFailureEventId = finalized.eventId; + modelFailureReason = finalized.failureReason; if (reply.diagnostics.outcome !== "success") { await deliverAssistantMessage(reply.text); @@ -542,6 +546,7 @@ async function runLocalAgentTurnInContext( createdAtMs: Date.now(), ...(modelFailureEventId ? { eventId: modelFailureEventId } : undefined), failureCode: "model_execution_failed", + ...(modelFailureReason ? { failureReason: modelFailureReason } : undefined), turnId, }); } diff --git a/packages/junior/src/chat/providers/slack/resume.ts b/packages/junior/src/chat/providers/slack/resume.ts index e57b4c9342..696617802f 100644 --- a/packages/junior/src/chat/providers/slack/resume.ts +++ b/packages/junior/src/chat/providers/slack/resume.ts @@ -750,6 +750,9 @@ async function resumeSlackTurnInContext( outcome: "failed", failureCode: "model_execution_failed", ...(finalized.eventId ? { eventId: finalized.eventId } : undefined), + ...(finalized.failureReason + ? { failureReason: finalized.failureReason } + : undefined), }; }, replyTimeoutMs, diff --git a/packages/junior/src/chat/providers/slack/turn.ts b/packages/junior/src/chat/providers/slack/turn.ts index 93fb558010..aa3158df8d 100644 --- a/packages/junior/src/chat/providers/slack/turn.ts +++ b/packages/junior/src/chat/providers/slack/turn.ts @@ -1170,6 +1170,9 @@ export function createSlackTurn(deps: SlackTurnDeps) { getAgentTurnDiagnosticsAttributes(finalResult); setSpanAttributes(diagnosticsAttributes); let failureEventId: string | undefined; + let failureReason: + | ReturnType["failureReason"] + | undefined; if (finalResult.diagnostics.outcome !== "success") { const finalized = finalizeFailedTurnReplyWithEvent({ reply: finalResult, @@ -1177,6 +1180,7 @@ export function createSlackTurn(deps: SlackTurnDeps) { }); finalResult = finalized.reply; failureEventId = finalized.eventId; + failureReason = finalized.failureReason; await deliverAssistantMessage(finalResult.text); } const turnResult: DispatchTurnResult = @@ -1298,6 +1302,7 @@ export function createSlackTurn(deps: SlackTurnDeps) { return { ...(failureEventId ? { eventId: failureEventId } : undefined), failureCode: "model_execution_failed" as const, + ...(failureReason ? { failureReason } : undefined), outcome: "failed" as const, }; }; diff --git a/packages/junior/src/chat/runtime/turn-execution.ts b/packages/junior/src/chat/runtime/turn-execution.ts index a615d65b23..bafe70e44f 100644 --- a/packages/junior/src/chat/runtime/turn-execution.ts +++ b/packages/junior/src/chat/runtime/turn-execution.ts @@ -21,6 +21,7 @@ type SavedTurnResult = eventId?: string; finishedAtMs?: number; failureCode: FailConversationTurnInput["failureCode"]; + failureReason?: FailConversationTurnInput["failureReason"]; outcome: "failed"; }; @@ -75,6 +76,9 @@ export async function executeTurn( ...common, ...(saved.eventId ? { eventId: saved.eventId } : undefined), failureCode: saved.failureCode, + ...(saved.failureReason + ? { failureReason: saved.failureReason } + : undefined), }); } else { await lifecycle.complete({ diff --git a/packages/junior/src/chat/services/turn-failure-response.ts b/packages/junior/src/chat/services/turn-failure-response.ts index fa40a29aac..c86ee9f819 100644 --- a/packages/junior/src/chat/services/turn-failure-response.ts +++ b/packages/junior/src/chat/services/turn-failure-response.ts @@ -1,3 +1,4 @@ +import type { ConversationTurnFailureReason } from "@/chat/conversations/history"; import { buildTurnFailureResponse } from "@/chat/logging"; import { getInterruptionMarker } from "@/chat/interruption-marker"; import { @@ -45,6 +46,31 @@ function getExecutionFailureReason(reply: { return "empty assistant turn"; } +/** + * Return a privacy-safe reason for a failed model turn. + * Prefer provider kinds; otherwise use a stable execution class. + */ +export function getTurnFailureReason( + reply: AgentRunResult, +): ConversationTurnFailureReason | undefined { + if (reply.diagnostics.outcome === "success") { + return undefined; + } + if (reply.diagnostics.outcome === "provider_error") { + return findProviderError(reply.diagnostics.providerError)?.kind ?? "unknown"; + } + if (reply.diagnostics.toolErrorCount > 0) { + return "tool_errors"; + } + if ( + reply.diagnostics.usedPrimaryText && + reply.diagnostics.assistantMessageCount > 0 + ) { + return "suppressed_output"; + } + return "empty_output"; +} + function getFailureCapture(reply: AgentRunResult): { attributes: Record; body: string; @@ -110,6 +136,7 @@ export function getAgentTurnDiagnosticsAttributes( /** Sanitized failure fallback plus its optional captured event ID. */ export interface FinalizedTurnFailure { eventId?: string; + failureReason?: ConversationTurnFailureReason; reply: AgentRunResult; } @@ -123,12 +150,16 @@ export function finalizeFailedTurnReplyWithEvent(args: { return { reply: args.reply }; } + const failureReason = getTurnFailureReason(args.reply); const capture = getFailureCapture(args.reply); const eventId = requireTurnFailureEventId( args.logException(capture.error, capture.eventName, { ...getAgentTurnDiagnosticsAttributes(args.reply), ...args.attributes, ...capture.attributes, + ...(failureReason + ? { "app.ai.failure_reason": failureReason } + : undefined), }), capture.eventName, ); @@ -152,6 +183,7 @@ export function finalizeFailedTurnReplyWithEvent(args: { return { eventId, + ...(failureReason ? { failureReason } : undefined), reply: { ...args.reply, text: providerPartialText diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 8b1332599a..b1a060e3c0 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -833,6 +833,7 @@ describe("conversation report event projection", () => { type: "turn_failed", turnId: "turn-1", failureCode: "model_execution_failed", + failureReason: "network", eventId, }), event(5, { @@ -891,6 +892,7 @@ describe("conversation report event projection", () => { turnId: "turn-1", state: "failed", failureCode: "model_execution_failed", + failureReason: "network", }); expect(projected[4]?.data).toEqual({ type: "turn_lifecycle", diff --git a/packages/junior/tests/unit/services/turn-failure-response.test.ts b/packages/junior/tests/unit/services/turn-failure-response.test.ts index 84b5f98471..4346fab2a6 100644 --- a/packages/junior/tests/unit/services/turn-failure-response.test.ts +++ b/packages/junior/tests/unit/services/turn-failure-response.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { getInterruptionMarker } from "@/chat/interruption-marker"; import { createProviderError } from "@/chat/services/provider-error"; -import { finalizeFailedTurnReply } from "@/chat/services/turn-failure-response"; +import { + finalizeFailedTurnReply, + finalizeFailedTurnReplyWithEvent, + getTurnFailureReason, +} from "@/chat/services/turn-failure-response"; import type { AgentRunResult } from "@/chat/services/turn-result"; function providerErrorReply(args: { @@ -52,7 +56,7 @@ describe("finalizeFailedTurnReply", () => { { modelId: "xai/grok-4.5" }, ); - finalizeFailedTurnReply({ + const finalized = finalizeFailedTurnReplyWithEvent({ reply: providerErrorReply({ assistantMessageCount: 0, errorMessage: providerError.message, @@ -62,16 +66,35 @@ describe("finalizeFailedTurnReply", () => { logException, }); + expect(finalized.failureReason).toBe("server"); const attributes = logException.mock.calls[0]?.[2]; expect(attributes).toMatchObject({ "app.ai.provider_error.kind": "server", "app.ai.provider_error.retryable": true, "app.ai.provider_error.status": 503, + "app.ai.failure_reason": "server", "gen_ai.request.model": "xai/grok-4.5", }); expect(attributes).not.toHaveProperty("exception.message"); }); + it("classifies empty execution failures without raw exception text", () => { + const reply: AgentRunResult = { + text: "", + diagnostics: { + outcome: "execution_failure", + modelId: "test-model", + assistantMessageCount: 0, + toolCalls: [], + toolResultCount: 0, + toolErrorCount: 0, + usedPrimaryText: false, + }, + }; + + expect(getTurnFailureReason(reply)).toBe("empty_output"); + }); + it("records a provider failure preserved inside a domain error", () => { const logException = vi.fn().mockReturnValue("evt_guardian"); const providerError = createProviderError("No object generated", { From 32e16c250ad3ae294e37c900382e7a9e2425e96c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:44:29 +0000 Subject: [PATCH 4/5] refactor(chat): dedupe failure schemas and plain failure copy Share turn failure schemas from history, drop developer jargon from UI copy, and keep provider error kinds owned by the provider boundary. Co-Authored-By: David Cramer --- .../conversations/TranscriptFailureView.tsx | 4 -- .../client/conversations/transcriptFailure.ts | 41 +++++++++---------- .../src/client/markdownExport.ts | 4 +- .../tests/markdownExport.test.ts | 8 ++-- .../tests/telemetry-components.test.tsx | 6 +-- .../junior/src/api/schema/conversation.ts | 36 ++++------------ .../junior/src/chat/conversations/history.ts | 10 ++--- .../src/chat/conversations/turn-lifecycle.ts | 4 +- packages/junior/src/chat/local/runner.ts | 9 ++-- .../junior/src/chat/providers/slack/turn.ts | 5 +-- .../chat/services/turn-failure-response.ts | 5 +-- 11 files changed, 52 insertions(+), 80 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx index 02ea516a43..df161726a9 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptFailureView.tsx @@ -17,7 +17,6 @@ export function TranscriptFailureView(props: { timestamp?: number; }) { const timestamp = formatMessageTimestamp(props.timestamp); - const detail = props.failureReason ?? props.failureCode; return (
{transcriptFailureDescription(props.failureCode, props.failureReason)}
-
- {detail} -
{timestamp ? ( diff --git a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts index b914e6fbd9..a847ca52b0 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts @@ -13,7 +13,7 @@ export function transcriptFailureTitle( if (failureCode === "model_execution_failed" && failureReason) { switch (failureReason) { case "auth": - return "Model auth failed"; + return "Model credentials rejected"; case "permission": return "Model access denied"; case "rate_limit": @@ -23,9 +23,9 @@ export function transcriptFailureTitle( case "timeout": return "Model timed out"; case "network": - return "Model network error"; + return "Model connection failed"; case "server": - return "Model provider error"; + return "Model service error"; case "invalid_request": return "Invalid model request"; case "invalid_response": @@ -37,11 +37,11 @@ export function transcriptFailureTitle( case "empty_output": return "Empty model response"; case "tool_errors": - return "Tool errors stopped the turn"; + return "Tool failed"; case "suppressed_output": return "Model output was dropped"; case "unknown": - return "Model provider error"; + return "Model service error"; } } @@ -49,7 +49,7 @@ export function transcriptFailureTitle( case "delivery_failed": return "Message delivery failed"; case "model_execution_failed": - return "Model execution failed"; + return "Model failed"; case "persistence_failed": return "Save failed"; case "agent_run_failed": @@ -67,35 +67,35 @@ export function transcriptFailureDescription( if (failureCode === "model_execution_failed" && failureReason) { switch (failureReason) { case "auth": - return "The model provider rejected credentials. An admin needs to fix configuration."; + return "The model service rejected credentials. An admin needs to fix configuration."; case "permission": - return "The model provider denied access to this model or request."; + return "The model service denied access to this model or request."; case "rate_limit": - return "The model is rate-limited. Try again shortly."; + return "The model is rate limited. Try again shortly."; case "capacity": return "The selected model is at capacity. Try again shortly."; case "timeout": - return "The model provider timed out before the turn finished."; + return "The model service timed out before the turn finished."; case "network": - return "The model provider had a network problem before the turn finished."; + return "The model service had a connection problem before the turn finished."; case "server": - return "The model provider returned a server error."; + return "The model service returned an error."; case "invalid_request": - return "The model provider rejected this request as invalid."; + return "The model service rejected this request as invalid."; case "invalid_response": - return "The model provider returned an invalid response."; + return "The model service returned an invalid response."; case "quota": - return "The model provider quota is exhausted. An admin needs to fix billing or limits."; + return "The model service quota is exhausted. An admin needs to fix billing or limits."; case "content_policy": - return "The model provider blocked this request under its content policy."; + return "The model service blocked this request under its content policy."; case "empty_output": return `The model returned no usable text before ${agentName} finished this turn.`; case "tool_errors": - return "One or more tools failed and the turn could not finish."; + return "A tool failed and the turn could not finish."; case "suppressed_output": return `The model produced text that ${agentName} could not deliver.`; case "unknown": - return "The model provider failed for an unknown reason."; + return "The model service failed for an unknown reason."; } } @@ -120,9 +120,6 @@ export function transcriptFailureSearchText( transcriptFailureTitle(failureCode, failureReason), transcriptFailureDescription(failureCode, failureReason), failureCode.replaceAll("_", " "), - failureCode, - ...(failureReason - ? [failureReason.replaceAll("_", " "), failureReason] - : []), + ...(failureReason ? [failureReason.replaceAll("_", " ")] : []), ].join(" "); } diff --git a/packages/junior-dashboard/src/client/markdownExport.ts b/packages/junior-dashboard/src/client/markdownExport.ts index de6fb7df4f..e3f8e23339 100644 --- a/packages/junior-dashboard/src/client/markdownExport.ts +++ b/packages/junior-dashboard/src/client/markdownExport.ts @@ -231,9 +231,9 @@ function appendFailure( ); addEventMeta(lines, conversationTranscript, timestamp); lines.push("", transcriptFailureDescription(failureCode, failureReason)); - addMetaLine(lines, "Failure code", failureCode); + addMetaLine(lines, "Code", failureCode); if (failureReason) { - addMetaLine(lines, "Failure reason", failureReason); + addMetaLine(lines, "Reason", failureReason); } } diff --git a/packages/junior-dashboard/tests/markdownExport.test.ts b/packages/junior-dashboard/tests/markdownExport.test.ts index 79c36a8b60..d1d62d7ff7 100644 --- a/packages/junior-dashboard/tests/markdownExport.test.ts +++ b/packages/junior-dashboard/tests/markdownExport.test.ts @@ -293,9 +293,9 @@ describe("dashboard canonical-event Markdown export", () => { expect(markdown).toContain("- Model: openai/gpt-5-mini"); expect(markdown).toContain("- Reasoning: medium"); expect(markdown).toContain("Investigate the remaining deployment failure."); - expect(markdown).toContain("### Model network error"); - expect(markdown).toContain("model_execution_failed"); - expect(markdown).toContain("network"); + expect(markdown).toContain("### Model connection failed"); + expect(markdown).toContain("- Code: model_execution_failed"); + expect(markdown).toContain("- Reason: network"); expect(markdown).not.toContain("missing"); expect(markdown).not.toContain("Result: running"); }); @@ -315,7 +315,7 @@ describe("dashboard canonical-event Markdown export", () => { expect(markdown).toContain("### Message delivery failed"); expect(markdown).toContain("Junior could not deliver this message."); expect(markdown).not.toContain("turn-1"); - expect(markdown).not.toContain("Model network error"); + expect(markdown).not.toContain("Model connection failed"); expect(markdown).not.toContain("Internal error"); }); diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index a08d84ddd1..59fe601f21 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -695,8 +695,8 @@ describe("dashboard canonical-event components", () => { ); expect(html).toContain("Context compacted"); expect(html).toContain("Model handoff"); - expect(html).toContain("Model network error"); - expect(html).toContain("network"); + expect(html).toContain("Model connection failed"); + expect(html).toContain('data-transcript-failure-reason="network"'); }); it("anchors structured events to the transcript rail", () => { @@ -817,7 +817,7 @@ describe("dashboard canonical-event components", () => { ); expect(html).toContain("Message delivery failed"); expect(html).toContain("Junior could not deliver this message."); - expect(html).not.toContain("Model network error"); + expect(html).not.toContain("Model connection failed"); expect(html).not.toContain("Internal error"); }); diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 4cd8b37d66..da2a1d5877 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + conversationTurnFailureCodeSchema, + conversationTurnFailureReasonSchema, +} from "@/chat/conversations/history"; import { usageCostSchema, usageSchema } from "@/usage-schema"; import { conversationAnnotationInputSchema, @@ -6,6 +10,11 @@ import { conversationEventPresentationSchema, } from "@sentry/junior-plugin-api"; +export { + conversationTurnFailureCodeSchema, + conversationTurnFailureReasonSchema, +}; + export const conversationReportStatusSchema = z.enum([ "active", "completed", @@ -400,33 +409,6 @@ const conversationReportAssistantMessageEventDataSchema = z }) .strict(); -/** Stable failure codes exposed on failed turn lifecycle report events. */ -export const conversationTurnFailureCodeSchema = z.enum([ - "agent_run_failed", - "delivery_failed", - "model_execution_failed", - "persistence_failed", -]); - -/** Stable failure reasons exposed on failed turn lifecycle report events. */ -export const conversationTurnFailureReasonSchema = z.enum([ - "auth", - "permission", - "rate_limit", - "capacity", - "timeout", - "network", - "server", - "invalid_request", - "invalid_response", - "quota", - "content_policy", - "unknown", - "empty_output", - "tool_errors", - "suppressed_output", -]); - const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( "state", [ diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index 871878f77e..45ed2ed47c 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -236,7 +236,7 @@ export const conversationTurnSurfaceSchema = z.enum([ "internal", ]); -/** Stable, privacy-safe classification for a failed turn. */ +/** Where a failed turn stopped. */ export const conversationTurnFailureCodeSchema = z.enum([ "agent_run_failed", "delivery_failed", @@ -244,14 +244,14 @@ export const conversationTurnFailureCodeSchema = z.enum([ "persistence_failed", ]); -/** Failure classification persisted without raw provider or exception data. */ +/** Where a failed turn stopped. */ export type ConversationTurnFailureCode = z.output< typeof conversationTurnFailureCodeSchema >; /** - * Stable reason for a failed turn. - * Provider kinds stay privacy-safe; execution classes avoid raw exception text. + * Why a failed turn stopped. + * Values are fixed labels only. Do not store raw exception text. */ export const conversationTurnFailureReasonSchema = z.enum([ "auth", @@ -271,7 +271,7 @@ export const conversationTurnFailureReasonSchema = z.enum([ "suppressed_output", ]); -/** Privacy-safe reason persisted on a failed turn. */ +/** Why a failed turn stopped. */ export type ConversationTurnFailureReason = z.output< typeof conversationTurnFailureReasonSchema >; diff --git a/packages/junior/src/chat/conversations/turn-lifecycle.ts b/packages/junior/src/chat/conversations/turn-lifecycle.ts index 18c5278f24..31f3761dfd 100644 --- a/packages/junior/src/chat/conversations/turn-lifecycle.ts +++ b/packages/junior/src/chat/conversations/turn-lifecycle.ts @@ -21,7 +21,7 @@ export interface CompleteConversationTurnInput { turnId: string; } -/** Privacy-safe inputs for failing one correlated conversation turn. */ +/** Inputs for failing one correlated conversation turn. */ export interface FailConversationTurnInput { conversationId: string; createdAtMs: number; @@ -73,7 +73,7 @@ export class ConversationTurnLifecycleService implements ConversationTurnLifecyc ]); } - /** Record a classified failure once without accepting raw error details. */ + /** Record a classified failure once. Do not store raw error text. */ async fail(input: FailConversationTurnInput): Promise { await this.events.append(input.conversationId, [ { diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 6a4b692a98..41560e8b56 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -49,7 +49,10 @@ import { import { credentialContextForActor } from "@/chat/credentials/context"; import { getConversationEventStore, getConversationStore } from "@/chat/db"; import { ConversationTurnLifecycleService } from "@/chat/conversations/turn-lifecycle"; -import type { ConversationTurnFailureCode } from "@/chat/conversations/history"; +import type { + ConversationTurnFailureCode, + ConversationTurnFailureReason, +} from "@/chat/conversations/history"; import { persistConversationMessages } from "@/chat/conversations/messages"; import { persistWithRetry } from "@/chat/services/persist-retry"; import { completeAuthPauseTurn } from "@/chat/runtime/auth-pause-state"; @@ -245,9 +248,7 @@ async function runLocalAgentTurnInContext( let completedState: ReturnType; let failureCode: ConversationTurnFailureCode = "persistence_failed"; let modelFailureEventId: string | undefined; - let modelFailureReason: - | ReturnType["failureReason"] - | undefined; + let modelFailureReason: ConversationTurnFailureReason | undefined; let modelFailureCaptureAttempted = false; let currentRunId: string | undefined; let completionSliceId = 1; diff --git a/packages/junior/src/chat/providers/slack/turn.ts b/packages/junior/src/chat/providers/slack/turn.ts index aa3158df8d..d482a82ff1 100644 --- a/packages/junior/src/chat/providers/slack/turn.ts +++ b/packages/junior/src/chat/providers/slack/turn.ts @@ -114,6 +114,7 @@ import { } from "@/chat/runtime/turn"; import { buildDeterministicTurnId } from "@/chat/runtime/turn"; import { buildDeterministicAssistantMessageId } from "@/chat/state/turn-id"; +import type { ConversationTurnFailureReason } from "@/chat/conversations/history"; import { markTurnClosed, markTurnFailed } from "@/chat/runtime/turn"; import { startActiveTurn } from "@/chat/runtime/turn"; import { @@ -1170,9 +1171,7 @@ export function createSlackTurn(deps: SlackTurnDeps) { getAgentTurnDiagnosticsAttributes(finalResult); setSpanAttributes(diagnosticsAttributes); let failureEventId: string | undefined; - let failureReason: - | ReturnType["failureReason"] - | undefined; + let failureReason: ConversationTurnFailureReason | undefined; if (finalResult.diagnostics.outcome !== "success") { const finalized = finalizeFailedTurnReplyWithEvent({ reply: finalResult, diff --git a/packages/junior/src/chat/services/turn-failure-response.ts b/packages/junior/src/chat/services/turn-failure-response.ts index c86ee9f819..467c402fb0 100644 --- a/packages/junior/src/chat/services/turn-failure-response.ts +++ b/packages/junior/src/chat/services/turn-failure-response.ts @@ -46,10 +46,7 @@ function getExecutionFailureReason(reply: { return "empty assistant turn"; } -/** - * Return a privacy-safe reason for a failed model turn. - * Prefer provider kinds; otherwise use a stable execution class. - */ +/** Return a fixed reason label for a failed model turn. */ export function getTurnFailureReason( reply: AgentRunResult, ): ConversationTurnFailureReason | undefined { From 2a360820267c2cd90e403c80a00b7967dbb656af Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:47:30 +0000 Subject: [PATCH 5/5] fix(dashboard): make failure copy switches exhaustively return CI typecheck failed because nested failure switches did not prove a return on every path. --- .../src/client/conversations/transcriptFailure.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts index a847ca52b0..a95c2f8bf6 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptFailure.ts @@ -55,6 +55,8 @@ export function transcriptFailureTitle( case "agent_run_failed": return "Internal error"; } + + return "Internal error"; } /** Short description for one failed turn in the transcript. */ @@ -109,6 +111,8 @@ export function transcriptFailureDescription( case "agent_run_failed": return `${agentName} hit an internal error during this turn.`; } + + return `${agentName} hit an internal error during this turn.`; } /** Search text for one failed turn in the transcript. */