diff --git a/packages/views/agents/components/tabs/task-failure.test.ts b/packages/views/agents/components/tabs/task-failure.test.ts index db47afe68..a3d992840 100644 --- a/packages/views/agents/components/tabs/task-failure.test.ts +++ b/packages/views/agents/components/tabs/task-failure.test.ts @@ -13,7 +13,10 @@ import { FAILURE_REASON_I18N_KEYS, cancellationActorLabel, cancelReasonLabel, + failureNeedsAction, failureReasonLabel, + isCancelledOutcome, + runOutcomeLabel, } from "./task-failure"; const AGENT_RESOURCES = { @@ -217,3 +220,32 @@ describe("failureReasonLabel", () => { expect(failureReasonLabel("", enT)).toBeNull(); }); }); + +describe("run outcome", () => { + it("reads a failed row with a cancellation reason as cancelled", () => { + expect(isCancelledOutcome({ status: "failed", failure_reason: "cancelled" })).toBe(true); + expect(isCancelledOutcome({ status: "failed", failure_reason: "user_cancelled" })).toBe(true); + expect(isCancelledOutcome({ status: "cancelled" })).toBe(true); + expect(isCancelledOutcome({ status: "failed", failure_reason: "timeout" })).toBe(false); + expect(isCancelledOutcome({ status: "failed" })).toBe(false); + }); + + it("flags only failures that need a configuration change", () => { + expect(failureNeedsAction({ status: "failed", failure_reason: "agent_error.provider_auth_or_access" })).toBe(true); + expect(failureNeedsAction({ status: "failed", failure_reason: "runtime_access_denied" })).toBe(true); + expect(failureNeedsAction({ status: "failed", failure_reason: "agent_error.provider_capacity_or_rate_limit" })).toBe(false); + expect(failureNeedsAction({ status: "cancelled", failure_reason: "runtime_access_denied" })).toBe(false); + }); + + it("labels a run once: reason first, then who cancelled it", () => { + expect(runOutcomeLabel({ status: "failed", failure_reason: "cancelled" }, enT)).toBe("Cancelled by the system"); + expect(runOutcomeLabel({ status: "failed", failure_reason: null }, enT)).toBeNull(); + expect(runOutcomeLabel({ + status: "cancelled", failure_reason: "queued_expired", cancelled_by: { type: "system" }, + }, enT)).toBe("Expired in queue"); + expect(runOutcomeLabel({ + status: "cancelled", cancelled_by: { type: "member", name: "Jiayuan" }, + }, enT)).toBe("Cancelled by Jiayuan"); + expect(runOutcomeLabel({ status: "cancelled" }, enT)).toBeNull(); + }); +}); diff --git a/packages/views/agents/components/tabs/task-failure.ts b/packages/views/agents/components/tabs/task-failure.ts index 383016740..2a09b6639 100644 --- a/packages/views/agents/components/tabs/task-failure.ts +++ b/packages/views/agents/components/tabs/task-failure.ts @@ -123,3 +123,51 @@ export function cancellationActorLabel( ? t(($) => $.task_failure.cancelled_by_actor, { name }) : null; } + +// Wire values that record a cancellation, not an error. A run the platform +// stopped reads as cancelled even when its row carries status `failed`. +const CANCELLATION_FAILURE_REASONS: ReadonlySet = new Set([ + "cancelled", + "manual", + "user_cancelled", +]); + +// Failures a retry cannot fix until someone changes configuration: the +// non-retryable auth, quota, config, model, CLI, and runtime-ownership +// reasons documented in server/pkg/taskfailure. +const ACTION_REQUIRED_FAILURE_REASONS: ReadonlySet = new Set([ + "runtime_access_denied", + "agent_error.provider_auth_or_access", + "agent_error.provider_quota_limit", + "agent_error.missing_config", + "agent_error.model_not_found_or_unavailable", + "agent_error.runtime_version_unsupported", + "agent_error.runtime_missing_executable", +]); + +type TaskOutcome = { + status: string; + error?: string | null; + failure_reason?: string | null; + cancelled_by?: { type: string; name?: string }; +}; + +/** A run that was stopped rather than one that broke. */ +export function isCancelledOutcome(task: TaskOutcome): boolean { + return task.status === "cancelled" + || (task.status === "failed" && CANCELLATION_FAILURE_REASONS.has(task.failure_reason ?? "")); +} + +/** A failed run whose recovery is a configuration change, not a retry. */ +export function failureNeedsAction(task: TaskOutcome): boolean { + return task.status === "failed" && ACTION_REQUIRED_FAILURE_REASONS.has(task.failure_reason ?? ""); +} + +/** + * The one label for how a failed or cancelled run ended: its recorded reason, + * else who cancelled it. Null when only the plain status is known. + */ +export function runOutcomeLabel(task: TaskOutcome, t: AgentsT): string | null { + if (task.status === "failed") return failureReasonLabel(task.failure_reason, t); + return cancelReasonLabel(task, t) ?? cancellationActorLabel(task, t); +} diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 2a9410c96..a846ed3ac 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -51,7 +51,7 @@ import { CommentsFoldBar } from "./resolved-thread-bar"; import { deriveThreadResolution } from "./thread-utils"; import { RevisionConflictCompare } from "./revision-conflict-compare"; import { InlineCommentRun, PlacedInlineCommentRun, useInlineCommentRunState, type InlineCommentRunState } from "./inline-comment-run"; -import { EMPTY_COMMENT_RUNS, orderThreadWithRuns, type CommentRun, type ThreadRunSlot } from "./comment-runs"; +import { EMPTY_COMMENT_RUNS, isRunFailureNotice, orderThreadWithRuns, type CommentRun, type ThreadRunSlot } from "./comment-runs"; import { descriptionPreview } from "./description-preview"; import { useCommentAnnotations } from "./use-comment-annotations"; import { useRunCommentMotion } from "./use-run-comment-motion"; @@ -993,13 +993,21 @@ export function AgentRunComment({ run, standalone = false, commentProps, enterin // it still carries `hasReply`, so it never prints the deleted body as its // output. A deleted thread ROOT keeps rendering — it heads its own thread. const replyHidden = !standalone && !!replyEntry && isDeletedComment(replyEntry); - const reply = replyHidden ? undefined : replyEntry; + // The platform's failure notice only restates how the run ended, so the + // run's own block takes its slot and says it once (MUL-7692). A standalone + // notice that people replied to still heads its thread as a card. + const notice = replyEntry && !replyHidden && isRunFailureNotice(replyEntry, run.task) + && !(standalone && commentProps?.replies.some((entry) => !isDeletedComment(entry))) + ? replyEntry : undefined; + const reply = replyHidden || notice ? undefined : replyEntry; + // The notice keeps its deep-link target and highlight in the run's slot. + const slotEntry = reply ?? notice; const motionRef = useRunCommentMotion(entering, reply?.id, run.task.status); return (
+ id={!standalone && slotEntry ? `comment-${slotEntry.id}` : undefined} + className={cn(standalone ? !reply && "rounded-xl border bg-card" : "border-t border-border/50", !reply && "py-1.5", slotEntry && commentProps?.highlightedCommentId === slotEntry.id && highlightedCommentBackgroundClass)}> {commentProps && reply ? standalone ? ( ) : ( @@ -1010,7 +1018,7 @@ export function AgentRunComment({ run, standalone = false, commentProps, enterin runHeader={} runMetadata={} /> ) :
- +
}
); @@ -1088,9 +1096,13 @@ function CommentCardImpl({ // over. Every tombstone has at least one live descendant (the server prunes // one that loses its last reply), so no content hides behind this. const visibleReplies = allNestedReplies.filter((reply) => !isDeletedComment(reply)); + // A failure notice heading a thread keeps the thread's header; its run line + // below says how the run ended, so the raw body and its button are dropped. + const noticeRun = runs.find((run) => run.hasReply && run.commentId === entry.id && isRunFailureNotice(entry, run.task)); const renderRuns = (commentId: string, presentation: "inline" | "header" = "inline") => runs.filter((run) => run.commentId === commentId && run.hasReply && (!run.anchorCommentId || run.anchorCommentId === commentId || replyFolded)) - .map((run) => ); + .map((run) => ); // A run slot renders the run's latest comment, or its activity until it // posts one. @@ -1443,7 +1455,7 @@ function CommentCardImpl({ {edit.isDragOver && } - ) : ( + ) : !noticeRun && ( <>
$.reply.annotations.source_label, { name: entry.actor_name || getActorName(entry.actor_type, entry.actor_id) })} diff --git a/packages/views/issues/components/comment-runs.test.ts b/packages/views/issues/components/comment-runs.test.ts index 9d948a5ae..119f10e2b 100644 --- a/packages/views/issues/components/comment-runs.test.ts +++ b/packages/views/issues/components/comment-runs.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; import type { AgentTask, TimelineEntry } from "@multica/core/types"; -import { commentRunOutput, buildCommentRunView, orderThreadWithRuns, orderTimelineWithRuns, type CommentRun } from "./comment-runs"; +import { commentRunOutput, buildCommentRunView, isRunFailureNotice, orderThreadWithRuns, orderTimelineWithRuns, type CommentRun } from "./comment-runs"; import { collectThreadReplies } from "./thread-utils"; const groupCommentRuns = (...args: Parameters) => buildCommentRunView(...args).runs; @@ -304,6 +304,20 @@ describe("commentRunOutput", () => { }); }); +describe("isRunFailureNotice", () => { + const notice = comment("notice", { actor_type: "agent", comment_type: "system", source_task_id: "run", content: "task cancelled by server" }); + it("matches the platform's failure comment for its own ended run", () => { + expect(isRunFailureNotice(notice, task("run", { status: "failed" }))).toBe(true); + expect(isRunFailureNotice(notice, task("run", { status: "cancelled" }))).toBe(true); + }); + it("leaves authored replies, other runs, and live runs alone", () => { + expect(isRunFailureNotice({ ...notice, comment_type: "comment" }, task("run", { status: "failed" }))).toBe(false); + expect(isRunFailureNotice({ ...notice, actor_type: "system" }, task("run", { status: "failed" }))).toBe(false); + expect(isRunFailureNotice(notice, task("other", { status: "failed" }))).toBe(false); + expect(isRunFailureNotice(notice, task("run", { status: "running" }))).toBe(false); + }); +}); + describe("standaloneCommentRuns", () => { it.each(["queued", "dispatched", "running", "failed", "cancelled", "completed"] as const)("keeps an unanchored %s run visible", (status) => { const run = task("assignment", { status, delivered_comment_ids: [] }); @@ -489,6 +503,24 @@ describe("orderThreadWithRuns", () => { expect(thread([run], [root, progress, aside, final])).toEqual(["progress", "aside", "final ↩ root"]); }); + // A retry answers the same input as the attempt it retries, but it starts + // after that attempt ended; it reads below it, not above (MUL-7692). + it("keeps a retry below the attempt it retries", () => { + const root = comment("root", { created_at: at("10:00:00") }); + const ask = comment("ask", { parent_id: root.id, created_at: at("10:01:00") }); + const failed = asked("failed", ask, { status: "failed", started_at: at("10:01:05"), completed_at: at("10:14:00") }); + const notice = comment("notice", { actor_type: "agent", comment_type: "system", source_task_id: failed.id, + parent_id: ask.id, created_at: at("10:14:00") }); + const retry = asked("retry", ask, { status: "running", created_at: at("10:20:00"), parent_task_id: failed.id }); + const later = comment("later", { parent_id: root.id, created_at: at("10:25:00") }); + expect(thread([failed, retry], [root, ask, notice])).toEqual(["ask", "notice", "run:retry ↩ ask"]); + expect(thread([failed, retry], [root, ask, notice, later])).toEqual(["ask", "notice", "run:retry ↩ ask", "later"]); + const unanswered = asked("unanswered", ask, { status: "failed", completed_at: at("10:14:00") }); + const again = asked("again", ask, { status: "queued", delivered_comment_ids: [], created_at: at("10:20:00"), + parent_task_id: unanswered.id }); + expect(thread([unanswered, again], [root, ask])).toEqual(["ask", "run:unanswered", "run:again ↩ ask"]); + }); + it("settles a run that ended without replying at the time it ended", () => { const root = comment("root", { created_at: at("10:00:00") }); const failed = asked("failed", root, { status: "failed", started_at: at("10:00:10"), completed_at: at("10:05:00") }); diff --git a/packages/views/issues/components/comment-runs.ts b/packages/views/issues/components/comment-runs.ts index b866b7d9e..3acd52bd6 100644 --- a/packages/views/issues/components/comment-runs.ts +++ b/packages/views/issues/components/comment-runs.ts @@ -23,6 +23,18 @@ export function isActiveCommentRun(task: AgentTask): boolean { return ["queued", "dispatched", "waiting_local_directory", "running"].includes(task.status); } +/** + * The comment the platform posts for a run that ended in failure: the raw + * error, authored as the agent. It restates how the run ended, so the run + * block renders in its place instead of a second, differently worded copy + * (MUL-7692). + */ +export function isRunFailureNotice(entry: TimelineEntry, task: AgentTask): boolean { + return entry.actor_type === "agent" && entry.comment_type === "system" + && entry.source_task_id === task.id + && (task.status === "failed" || task.status === "cancelled"); +} + /** Published replies own their log entry even while the agent finishes its run. */ export function showCommentRunInHeader(run: CommentRun): boolean { return run.hasReply && (isActiveCommentRun(run.task) || run.task.status === "completed"); @@ -280,10 +292,21 @@ export function orderThreadWithRuns( const working = new Set(threadRuns.filter((run) => !run.hasReply && isActiveCommentRun(run.task))); const rows = orderTimelineWithRuns(replies, threadRuns.filter((run) => !working.has(run)), entryById); // `runs` arrive in enqueue order: each working run goes after the row that - // shows its input (none for the root) and after earlier runs on that input. + // shows its input (none for the root), after earlier runs on that input, + // and after anything that happened before it was enqueued. A retry answers + // the same input as the attempt it retries but starts after that attempt + // ended, so it reads below it (MUL-7692). for (const run of working) { + const enqueuedAt = Date.parse(run.task.created_at); let index = rows.findIndex((item) => ("task" in item ? publishedReply(item, entryById)?.id : item.id) === run.anchorCommentId) + 1; - while (working.has(rows[index] as CommentRun) && (rows[index] as CommentRun).anchorCommentId === run.anchorCommentId) index += 1; + while (index < rows.length) { + const row = rows[index]!; + const earlierRun = working.has(row as CommentRun) && (row as CommentRun).anchorCommentId === run.anchorCommentId; + const earlierRow = !working.has(row as CommentRun) + && ("task" in row ? runSortTime(row, entryById) : Date.parse(row.created_at)) <= enqueuedAt; + if (!earlierRun && !earlierRow) break; + index += 1; + } rows.splice(index, 0, run); } // The comment directly above a run, past the run's own earlier comments. A diff --git a/packages/views/issues/components/inline-comment-run.test.tsx b/packages/views/issues/components/inline-comment-run.test.tsx index 7d4a680b5..91ad9fa3c 100644 --- a/packages/views/issues/components/inline-comment-run.test.tsx +++ b/packages/views/issues/components/inline-comment-run.test.tsx @@ -1,4 +1,5 @@ import { act, cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; @@ -582,6 +583,36 @@ describe("InlineCommentRun", () => { await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); }); + it("states how a failed run ended once and keeps the raw error on hover", async () => { + setup(task({ status: "failed", failure_reason: "cancelled", error: "task cancelled by server", + completed_at: "2026-09-07T00:12:56Z" })); + const label = screen.getByText("Cancelled by the system"); + expect(screen.queryByText("Failed")).not.toBeInTheDocument(); + expect(screen.queryByText("task cancelled by server")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry run" })).toBeInTheDocument(); + await userEvent.hover(label); + expect(await screen.findByText("task cancelled by server")).toBeInTheDocument(); + }); + + it("keeps the raw error in view when recovery needs a configuration change", () => { + setup(task({ status: "failed", failure_reason: "agent_error.provider_auth_or_access", + error: "Invalid API key · Please run /login", completed_at: "2026-09-07T00:00:06Z" })); + expect(screen.getByText("Provider auth failed")).toBeInTheDocument(); + expect(screen.getByText("Invalid API key · Please run /login")).toBeInTheDocument(); + }); + + it("offers retry beside a reply only when that reply is the run's failure notice", () => { + const client = new QueryClient(); + const failed = task({ status: "failed", failure_reason: "timeout", error: "run timed out" }); + const view = (replacesFailureNotice: boolean) => + + ; + const { rerender } = renderWithI18n(view(false)); + expect(screen.queryByRole("button", { name: "Retry run" })).not.toBeInTheDocument(); + rerender(view(true)); + expect(screen.getByRole("button", { name: "Retry run" })).toBeInTheDocument(); + }); + it("shows who cancelled the run and keeps legacy rows readable", () => { const { rerender } = setup(task({ status: "cancelled", diff --git a/packages/views/issues/components/inline-comment-run.tsx b/packages/views/issues/components/inline-comment-run.tsx index bd01ad126..67350e2b5 100644 --- a/packages/views/issues/components/inline-comment-run.tsx +++ b/packages/views/issues/components/inline-comment-run.tsx @@ -22,9 +22,9 @@ import { buildSteps, groupSteps, isCallStep, isGroupRow, type TraceRow } from ". import { traceEventSummary, traceToolArgSummary } from "../../common/task-transcript/trace-event-presenter"; import { redactSecrets } from "../../common/task-transcript/redact"; import { ReadonlyContent } from "../../editor"; -import { useT } from "../../i18n"; +import { useLocale, useT, useTimeAgo } from "../../i18n"; import { formatDuration } from "../../agents/components/agent-activity-hover-content"; -import { cancellationActorLabel, cancelReasonLabel, failureReasonLabel } from "../../agents/components/tabs/task-failure"; +import { failureNeedsAction, isCancelledOutcome, runOutcomeLabel } from "../../agents/components/tabs/task-failure"; import { TerminateTaskConfirmDialog } from "./terminate-task-confirm-dialog"; import { TaskStatusIcon } from "./task-status-icon"; import { useStatusLabel } from "./task-run-labels"; @@ -64,7 +64,7 @@ export function PlacedInlineCommentRun({ presentation = "inline", ...props }: Pa return ; } -export function InlineCommentRun({ run, className, viewState, showIdentity = false, presentation = "inline", replyTo }: { +export function InlineCommentRun({ run, className, viewState, showIdentity = false, presentation = "inline", replyTo, replacesFailureNotice = false }: { run: CommentRun; className?: string; viewState?: InlineCommentRunState; @@ -72,14 +72,26 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal presentation?: "inline" | "header"; /** The input this run answers, under its identity like a reply's. */ replyTo?: ReactNode; + /** The run's reply is only its failure notice, which this block stands in for. */ + replacesFailureNotice?: boolean; }) { const { task, hasReply } = run; const { t } = useT("issues"); const { t: tAgents } = useT("agents"); const { getActorName } = useActorName(); + const locale = useLocale(); + const timeAgo = useTimeAgo(); const name = getActorName("agent", task.agent_id); const status = useStatusLabel(task.status); - const statusText = cancellationActorLabel(task, tAgents) ?? status; + const ended = task.status === "failed" || task.status === "cancelled"; + const cancelled = isCancelledOutcome(task); + // One label says how the run ended (MUL-7692). The raw error stays + // reachable: on hover, or in view when someone has to fix a setting. + const statusText = (ended ? runOutcomeLabel(task, tAgents) : null) ?? status; + const rawError = ended && task.error?.trim() ? redactSecrets(task.error.trim()) : ""; + const needsAction = failureNeedsAction(task); + // Without a reply, how the run ended is the block's content, not metadata. + const outcomeIsContent = showIdentity && ended; const active = isActiveCommentRun(task); const localViewState = useInlineCommentRunState(); const state = viewState ?? localViewState; @@ -110,9 +122,6 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal const end = active ? now : task.completed_at ? Date.parse(task.completed_at) : undefined; const elapsed = end !== undefined && Number.isFinite(Date.parse(start)) && Number.isFinite(end) ? formatDuration(start, end) : ""; - const failure = task.status === "failed" - ? failureReasonLabel(task.failure_reason, tAgents) - : cancelReasonLabel(task, tAgents); const output = !hasReply ? commentRunOutput(task) : null; const latest = steps.findLast((step) => step.kind !== "text" || step.item.content?.trim()); const pendingCall = steps.findLast((step) => isCallStep(step) && !step.result); @@ -216,16 +225,31 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal className={cn("@container/run min-w-0 py-2", className)} data-run-id={task.id}>
{showIdentity && <> - + {name} + {!active && task.completed_at && + + {timeAgo(task.completed_at)} + } /> + {new Date(task.completed_at).toLocaleString(locale)} + } } - - {statusText} + + {rawError && rawError !== statusText && !needsAction ? + {statusText}} /> + + {statusText} + {rawError} + + : {statusText}} }
@@ -296,7 +320,10 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
} {output &&
} - {failure &&

{failure}

} + {needsAction && rawError &&

+ {rawError} +

} {expanded &&
{isPending &&

{t(($) => $.inline_run.loading)}

} {isError &&
{t(($) => $.inline_run.load_failed)} diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index c73e9b5de..6aa8c70cd 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -13,10 +13,11 @@ import { DEFAULT_SUB_ISSUE_ROW_PROPERTIES, useSubIssueDisplayStore, } from "@multica/core/issues/stores/sub-issue-display-store"; +import enAgents from "../../locales/en/agents.json"; import enCommon from "../../locales/en/common.json"; import enIssues from "../../locales/en/issues.json"; -const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } }; +const TEST_RESOURCES = { en: { agents: enAgents, common: enCommon, issues: enIssues } }; const mockViewport = vi.hoisted(() => ({ isMobile: false })); @@ -1554,6 +1555,65 @@ describe("IssueDetail (shared)", () => { expect(container.querySelectorAll(`[data-run-id="${task.id}"]`)).toHaveLength(1); }); + describe("a run's failure notice (MUL-7692)", () => { + const failedRun = (overrides: Partial = {}): AgentTask => ({ + id: "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc", agent_id: "agent-1", runtime_id: "runtime-1", issue_id: "issue-1", + status: "failed", failure_reason: "cancelled", error: "task cancelled by server", priority: 0, + created_at: "2026-01-18T00:00:00Z", dispatched_at: "2026-01-18T00:00:01Z", started_at: "2026-01-18T00:00:02Z", + completed_at: "2026-01-18T00:12:58Z", result: null, ...overrides, + }); + const notice = (task: AgentTask, parent_id: string | null): TimelineEntry => ({ + type: "comment", id: "failure-notice", actor_type: "agent", actor_id: "agent-1", content: "task cancelled by server", + parent_id, created_at: "2026-01-18T00:12:58Z", updated_at: "2026-01-18T00:12:58Z", comment_type: "system", + source_task_id: task.id, + }); + const expectStatedOnce = (slot: HTMLElement) => { + expect(within(slot).getByText("Cancelled by the system")).toBeInTheDocument(); + expect(within(slot).queryByText("task cancelled by server")).not.toBeInTheDocument(); + expect(within(slot).queryByText("Failed")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Retry run" })).toHaveLength(1); + }; + + it("renders the run block in the notice's thread slot and retries that run", async () => { + const root = mockTimeline[0]!; + const trigger = { ...root, id: "user-reply", parent_id: root.id, content: "Please try this task" }; + const task = failedRun({ trigger_comment_id: trigger.id, delivered_comment_ids: [trigger.id] }); + mockApiObj.listTimeline.mockResolvedValue([root, trigger, notice(task, trigger.id)]); + mockApiObj.listTasksByIssue.mockResolvedValue([task]); + const { container } = renderIssueDetail(); + + await waitFor(() => expect(container.querySelector("#comment-failure-notice [data-run-id]")).not.toBeNull()); + const slot = container.querySelector("#comment-failure-notice") as HTMLElement; + expect(slot.getAttribute("data-run-comment-id")).toBe(task.id); + expectStatedOnce(slot); + fireEvent.click(within(slot).getByRole("button", { name: "Retry run" })); + await waitFor(() => expect(mockApiObj.rerunIssue).toHaveBeenCalledWith("issue-1", task.id)); + }); + + it("renders a top-level notice as the run block", async () => { + const task = failedRun(); + mockApiObj.listTimeline.mockResolvedValue([...mockTimeline, notice(task, null)]); + mockApiObj.listTasksByIssue.mockResolvedValue([task]); + const { container } = renderIssueDetail(); + + await waitFor(() => expect(container.querySelector(`[data-run-comment-id="${task.id}"]`)).not.toBeNull()); + expectStatedOnce(container.querySelector("#comment-failure-notice") as HTMLElement); + }); + + it("keeps a replied-to notice as its thread, without the raw body", async () => { + const task = failedRun(); + const reply: TimelineEntry = { ...mockTimeline[0]!, id: "reply-to-notice", parent_id: "failure-notice", + content: "Retrying after the deploy", created_at: "2026-01-18T00:20:00Z", updated_at: "2026-01-18T00:20:00Z" }; + mockApiObj.listTimeline.mockResolvedValue([...mockTimeline, notice(task, null), reply]); + mockApiObj.listTasksByIssue.mockResolvedValue([task]); + const { container } = renderIssueDetail(); + + await screen.findByText("Retrying after the deploy"); + await waitFor(() => expect(container.querySelector(`#comment-failure-notice [data-run-id="${task.id}"]`)).not.toBeNull()); + expectStatedOnce(container.querySelector("#comment-failure-notice") as HTMLElement); + }); + }); + // Details is creator + immutable timestamps, so it ranks below the // execution log, which is what people actually open the sidebar for. it("orders the Details section after the execution log", async () => {