mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(issues): state a failed or cancelled run once in the thread (MUL-7692) (#8813)
* feat(issues): state a failed or cancelled run once in the thread (MUL-7692) A run that ended in failure rendered as two stacked components: the platform's system comment (the raw daemon error, shown as if the agent had said it) with its own "Retry run" button, then the run line with "Failed", the duration, and a red reason. One outcome was stated three ways, and a failed row with failure_reason=cancelled read as both failed and cancelled. - The run's own failure notice no longer renders as a comment. The run block takes its slot (keeping the notice's deep-link id and highlight), in a thread and at the top level. A top-level notice that people replied to keeps its thread header and drops only the raw body and its button. - The run line states the outcome once: icon + reason (or who cancelled), then View activity, duration, and a single Retry run in the slot the running row's Stop occupies. The raw error moves to a tooltip on the reason. - Cancellation reasons (cancelled, manual, user_cancelled) read as cancelled with the neutral Ban icon; red is reserved for real failures. - Failures that need a configuration change (auth, quota, missing config or CLI, unavailable model, private runtime) keep the raw error in view under the row. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep a retry below the attempt it retries (MUL-7692) A working run in a thread was spliced directly after the input it answers. A retry answers the same input as the failed attempt, so while it ran it sat above that attempt's failure row, out of order. Place a working run after its input, after earlier runs on that input, and after any row that happened before the run was enqueued. Later comments still go below it, as MUL-7632 requires. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
multica-agent
parent
cf2b84708d
commit
1c908ea52c
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> = 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<string> = 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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div ref={motionRef} data-run-slot-id={run.task.id}
|
||||
data-run-comment-id={!reply ? run.task.id : undefined}
|
||||
id={!standalone && reply ? `comment-${reply.id}` : undefined}
|
||||
className={cn(standalone ? !reply && "rounded-xl border bg-card" : "border-t border-border/50", !reply && "py-1.5", reply && commentProps?.highlightedCommentId === reply.id && highlightedCommentBackgroundClass)}>
|
||||
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 ? (
|
||||
<CommentCard {...commentProps} runs={commentProps.runs ?? [run]} runViewState={viewState} />
|
||||
) : (
|
||||
@@ -1010,7 +1018,7 @@ export function AgentRunComment({ run, standalone = false, commentProps, enterin
|
||||
runHeader={<PlacedInlineCommentRun run={run} viewState={viewState} presentation="header" />}
|
||||
runMetadata={<PlacedInlineCommentRun run={run} viewState={viewState} />} />
|
||||
) : <div className="px-4 max-md:px-3">
|
||||
<InlineCommentRun run={run} viewState={viewState} showIdentity replyTo={replyTo} />
|
||||
<InlineCommentRun run={run} viewState={viewState} showIdentity replyTo={replyTo} replacesFailureNotice={!!notice} />
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -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) => <PlacedInlineCommentRun key={run.task.id} run={run} presentation={presentation} viewState={run.commentId === entry.id ? runViewState : undefined} />);
|
||||
.map((run) => <PlacedInlineCommentRun key={run.task.id} run={run} presentation={presentation}
|
||||
viewState={run.commentId === entry.id ? runViewState : undefined} replacesFailureNotice={run === noticeRun} />);
|
||||
|
||||
// A run slot renders the run's latest comment, or its activity until it
|
||||
// posts one.
|
||||
@@ -1443,7 +1455,7 @@ function CommentCardImpl({
|
||||
</div>
|
||||
{edit.isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
) : (
|
||||
) : !noticeRun && (
|
||||
<>
|
||||
<div tabIndex={currentUserId ? 0 : undefined} role="group"
|
||||
aria-label={t(($) => $.reply.annotations.source_label, { name: entry.actor_name || getActorName(entry.actor_type, entry.actor_id) })}
|
||||
|
||||
@@ -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<typeof buildCommentRunView>) => 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") });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => <QueryClientProvider client={client}>
|
||||
<InlineCommentRun run={{ task: failed, commentId: "comment", hasReply: true }} replacesFailureNotice={replacesFailureNotice} />
|
||||
</QueryClientProvider>;
|
||||
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",
|
||||
|
||||
@@ -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 <InlineCommentRun {...props} presentation={presentation} />;
|
||||
}
|
||||
|
||||
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}>
|
||||
<div ref={animationVisibility.ref} className="flex min-h-7 min-w-0 items-center gap-2" data-run-summary-row>
|
||||
{showIdentity && <>
|
||||
<ActorAvatar actorType="agent" actorId={task.agent_id} size="md" enableHoverCard />
|
||||
<ActorAvatar actorType="agent" actorId={task.agent_id} size="md" enableHoverCard showStatusDot />
|
||||
<span className="max-w-[30%] shrink-0 truncate text-body font-medium" title={name}>{name}</span>
|
||||
{!active && task.completed_at && <Tooltip>
|
||||
<TooltipTrigger render={<span className="shrink-0 cursor-default text-caption text-muted-foreground @max-[32rem]/run:hidden">
|
||||
{timeAgo(task.completed_at)}
|
||||
</span>} />
|
||||
<TooltipContent side="top">{new Date(task.completed_at).toLocaleString(locale)}</TooltipContent>
|
||||
</Tooltip>}
|
||||
</>}
|
||||
<span className={cn("flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5 whitespace-nowrap text-caption text-muted-foreground", showProgress && "sr-only")}
|
||||
<span className={cn("flex min-w-0 items-center gap-1.5 whitespace-nowrap",
|
||||
outcomeIsContent ? "text-body text-foreground" : "max-w-[50%] shrink-0 text-caption text-muted-foreground",
|
||||
showProgress && "sr-only")}
|
||||
role="status" data-run-status>
|
||||
<TaskStatusIcon status={task.status} /><span className="truncate" title={statusText}>{statusText}</span>
|
||||
<TaskStatusIcon status={cancelled ? "cancelled" : task.status} />
|
||||
{rawError && rawError !== statusText && !needsAction ? <Tooltip>
|
||||
<TooltipTrigger render={<span className="truncate">{statusText}</span>} />
|
||||
<TooltipContent side="top" className="flex-col items-start">
|
||||
<span>{statusText}</span>
|
||||
<span className="break-all font-mono text-muted-foreground">{rawError}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip> : <span className="truncate" title={statusText}>{statusText}</span>}
|
||||
</span>
|
||||
<button type="button"
|
||||
className={cn("flex min-w-0 items-center gap-1.5 rounded-xs py-1 text-left text-caption text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
showProgress ? "flex-1 text-body" : "order-last ml-auto shrink-0",
|
||||
showProgress ? "flex-1 text-body" : "ml-auto shrink-0",
|
||||
showIdentity && !showProgress && "@max-[32rem]/run:min-w-7 @max-[32rem]/run:justify-center")}
|
||||
aria-label={stepLabel ? `${activityLabel} · ${stepLabel}` : activityLabel}
|
||||
aria-expanded={expanded} aria-controls={expanded ? regionId : undefined}
|
||||
@@ -240,12 +264,12 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
|
||||
<span className={cn("shrink-0 whitespace-nowrap text-caption tabular-nums text-muted-foreground", showIdentity && !active && "@max-[32rem]/run:hidden")}>{elapsed}</span>
|
||||
{supplementButton}
|
||||
{stopButton}
|
||||
{!hasReply && (task.status === "failed" || task.status === "cancelled") && <Button
|
||||
size="sm" variant="ghost" className={cn("text-muted-foreground", showIdentity && "@max-[32rem]/run:size-7 @max-[32rem]/run:p-0")} disabled={retry.isPending || retry.isSuccess}
|
||||
{(!hasReply || replacesFailureNotice) && ended && <Button
|
||||
size="xs" variant="outline" className={cn(showIdentity && "@max-[32rem]/run:size-6 @max-[32rem]/run:p-0")} disabled={retry.isPending || retry.isSuccess}
|
||||
onClick={() => retry.mutate(task.id, { onError: (error) => toast.error(
|
||||
dispatchReasonCode(error) === "invocation_not_allowed" ? t(($) => $.execution_log.retry_blocked) : t(($) => $.execution_log.retry_failed),
|
||||
) })}>
|
||||
<RotateCcw className="size-3.5" /><span className={cn(showIdentity && "@max-[32rem]/run:sr-only")}>{t(($) => $.execution_log.retry_task_tooltip)}</span>
|
||||
<RotateCcw /><span className={cn(showIdentity && "@max-[32rem]/run:sr-only")}>{t(($) => $.execution_log.retry_task_tooltip)}</span>
|
||||
</Button>}
|
||||
</div>
|
||||
<div className={cn(showIdentity && "pl-8")}>
|
||||
@@ -296,7 +320,10 @@ export function InlineCommentRun({ run, className, viewState, showIdentity = fal
|
||||
</div>
|
||||
</div>}
|
||||
{output && <div className="mt-2 text-body"><ReadonlyContent content={redactSecrets(output)} /></div>}
|
||||
{failure && <p className="mt-1 text-caption text-destructive">{failure}</p>}
|
||||
{needsAction && rawError && <p title={rawError}
|
||||
className="mt-1.5 line-clamp-4 whitespace-pre-wrap break-words rounded-md border bg-muted/50 px-2.5 py-1.5 font-mono text-caption text-muted-foreground">
|
||||
{rawError}
|
||||
</p>}
|
||||
{expanded && <div id={regionId} className="mt-2 min-w-0 space-y-1">
|
||||
{isPending && <p className="text-caption text-muted-foreground">{t(($) => $.inline_run.loading)}</p>}
|
||||
{isError && <div role="alert" className="text-caption text-destructive">{t(($) => $.inline_run.load_failed)}
|
||||
|
||||
@@ -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> = {}): 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user