Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/browser/features/Messages/MessageRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,100 @@ This was typed by a user.
});
});

describe("MessageRenderer subagent failure rows", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.localStorage = globalThis.window.localStorage;
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
globalThis.localStorage = undefined as unknown as Storage;
});

function failureMessage(
errorType: string,
errorMessage: string
): DisplayedMessage & { type: "user" } {
return {
type: "user",
id: "subagent-failure",
historyId: "subagent-failure",
historySequence: 26,
isSynthetic: true,
content: `<mux_subagent_failure>
<task_id>task-failed</task_id>
<execution_version>wst_123:interrupted:2026-09-04T12:04:40.370Z</execution_version>
<execution_id>wst_123</execution_id>
<agent_type>exec</agent_type>
<error_type>${errorType}</error_type>
<error_message>
${errorMessage}
</error_message>
This sub-agent task failed terminally and will not produce a report. Do not re-await it.
</mux_subagent_failure>`,
};
}

test("distinguishes superseded turns from failures and collapses diagnostic metadata", () => {
const message = failureMessage("workspace_turn_superseded", "New input superseded this turn.");
const view = render(
<TooltipProvider>
<MessageRenderer message={message} />
</TooltipProvider>
);
expect(view.queryAllByText(/mux_subagent_failure/).length).toBe(0);
expect(view.queryByText("auto")).toBeNull();
expect(view.getByText("New input took over")).toBeDefined();
expect(view.queryByText("Subagent task failed")).toBeNull();
const details = view.getByText("Technical details").closest("details");
expect(details).not.toBeNull();
expect(details?.hasAttribute("open")).toBe(false);
fireEvent.click(view.getByText("Technical details"));
expect(details?.hasAttribute("open")).toBe(true);
expect(view.getByText("task-failed")).toBeDefined();
expect(view.getByText("wst_123")).toBeDefined();
expect(view.getByText("New input superseded this turn.")).toBeDefined();
});

test("shows unknown failure reasons as escaped text without requiring execution metadata", () => {
const error = '<img src=x onerror="alert(1)">\nWorker exited unexpectedly.';
const message = failureMessage("unknown_future_error", error);
message.content = message.content.replace(/<execution_(?:version|id)>[^\n]*\n/g, "");
const view = render(
<TooltipProvider>
<MessageRenderer message={message} />
</TooltipProvider>
);
expect(view.queryAllByText(/mux_subagent_failure/).length).toBe(0);
expect(view.getByText("Subagent task failed")).toBeDefined();
expect(view.getByText(/Worker exited unexpectedly/).textContent).toBe(error);
expect(view.container.querySelector("img")).toBeNull();
expect(view.queryByText("Execution ID")).toBeNull();
});

test("leaves user-authored lookalikes and malformed synthetic envelopes untouched", () => {
const valid = failureMessage("failed", "An error occurred.");
for (const message of [
{ ...valid, isSynthetic: false },
{ ...valid, content: valid.content.replace("</error_message>", "") },
{ ...valid, content: `${valid.content}\nAdditional context must not be lost.` },
]) {
const view = render(
<TooltipProvider>
<MessageRenderer message={message} />
</TooltipProvider>
);
expect(view.queryByText("Technical details")).toBeNull();
expect(view.getAllByText(/mux_subagent_failure/).length).toBeGreaterThan(0);
view.unmount();
}
});
});

describe("MessageRenderer background work wake rows", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
Expand Down
63 changes: 63 additions & 0 deletions src/browser/features/Messages/SubagentFailureMessageContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ArrowRightLeft, ChevronRight, CircleAlert } from "lucide-react";
import { cn } from "@/common/lib/utils";
import type { SubagentFailureEnvelope } from "@/common/utils/subagentFailureEnvelope";

export function SubagentFailureMessageContent(props: { failure: SubagentFailureEnvelope }) {
// A superseded turn stops reporting, but its workspace keeps running under the new input.
// Present that handoff without the alarming failure protocol or a misleading workspace error.
const isSuperseded = props.failure.errorType === "workspace_turn_superseded";
const StatusIcon = isSuperseded ? ArrowRightLeft : CircleAlert;
const metadata = [
["Task ID", props.failure.taskId],
["Error type", props.failure.errorType],
["Execution ID", props.failure.executionId],
["Execution version", props.failure.executionVersion],
];

return (
<div className="flex min-w-0 items-start gap-2.5">
<StatusIcon
aria-hidden="true"
className={cn("mt-0.5 size-4 shrink-0", isSuperseded ? "text-muted" : "text-error")}
/>
<div className="min-w-0 flex-1 text-sm text-[var(--color-user-text)]">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium">
{isSuperseded ? "New input took over" : "Subagent task failed"}
</span>
<span className="text-muted text-xs [overflow-wrap:anywhere]">
{props.failure.agentType}
</span>
</div>
<p className="text-muted mt-1 leading-relaxed [overflow-wrap:anywhere] whitespace-pre-wrap">
{isSuperseded
? "The workspace continues with the new input. This delegated turn won’t return a report."
: props.failure.errorMessage}
</p>
<details className="group mt-2">
<summary className="text-muted hover:text-foreground focus-visible:ring-ring flex w-fit cursor-pointer list-none items-center gap-1 rounded-sm text-xs focus-visible:ring-2 focus-visible:outline-none [&::-webkit-details-marker]:hidden">
<ChevronRight aria-hidden="true" className="size-3 shrink-0 group-open:rotate-90" />
Technical details
</summary>
<div className="mt-2 border-t border-[var(--color-user-border)] pt-2">
<dl className="space-y-1 text-xs">
{metadata.map(([label, value]) =>
value ? (
<div key={label} className="grid grid-cols-[7rem_minmax(0,1fr)] gap-2">
<dt className="text-muted">{label}</dt>
<dd className="font-mono [overflow-wrap:anywhere]">{value}</dd>
</div>
) : null
)}
</dl>
{isSuperseded && (
<p className="text-muted mt-2 text-xs leading-relaxed [overflow-wrap:anywhere] whitespace-pre-wrap">
{props.failure.errorMessage}
</p>
)}
</div>
</details>
</div>
</div>
);
}
16 changes: 14 additions & 2 deletions src/browser/features/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
parseSubagentReportEnvelope,
SubagentReportMessageContent,
} from "./SubagentReportMessageContent";
import { parseSubagentFailureEnvelope } from "@/common/utils/subagentFailureEnvelope";
import { SubagentFailureMessageContent } from "./SubagentFailureMessageContent";
import { TerminalOutput } from "./TerminalOutput";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
Expand Down Expand Up @@ -88,6 +90,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
// Only backend-authored synthetic messages may opt into protocol-aware presentation. A user who
// types a lookalike envelope should continue to see an ordinary escaped user message.
const subagentReport = isSynthetic ? parseSubagentReportEnvelope(content) : null;
const subagentFailure = isSynthetic ? parseSubagentFailureEnvelope(content) : null;
const structuredOutputJson = subagentReport
? formatSubagentStructuredOutput(subagentReport)
: undefined;
Expand Down Expand Up @@ -260,6 +263,13 @@ export const UserMessage: React.FC<UserMessageProps> = ({
{isInProgress ? "subagent update" : "subagent report"}
</span>
);
} else if (subagentFailure) {
label = (
<span className="bg-muted/20 text-muted flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase">
<Bot aria-hidden="true" className="h-3 w-3" />
subagent
</span>
);
} else if (isSynthetic) {
label = (
<span className="bg-muted/20 text-muted rounded-sm px-1.5 py-0.5 text-[10px] font-medium uppercase">
Expand All @@ -269,8 +279,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
}
const syntheticClassName = cn(
className,
isSynthetic && !subagentReport && "opacity-70",
subagentReport && "ml-0 w-full",
isSynthetic && !subagentReport && !subagentFailure && "opacity-70",
(subagentReport ?? subagentFailure) && "ml-0 w-full",
(isGoalContinuation || isBudgetLimitWrapup) && "italic"
);

Expand All @@ -286,6 +296,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
);
} else if (subagentReport) {
renderedContent = <SubagentReportMessageContent report={subagentReport} />;
} else if (subagentFailure) {
renderedContent = <SubagentFailureMessageContent failure={subagentFailure} />;
} else {
renderedContent = (
<UserMessageContent
Expand Down
7 changes: 6 additions & 1 deletion src/browser/stories/App.subagentReportsDesktop.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** Full-app visual coverage for sub-agent progress and terminal report presentation. */

import { appMeta, AppWithMocks, PIXEL_DUAL_THEME, type AppStory } from "./meta.js";
import { setupSubagentReportStory } from "./helpers/subagentReportStory";
import { setupSubagentFailureStory, setupSubagentReportStory } from "./helpers/subagentReportStory";

export default {
...appMeta,
Expand All @@ -21,3 +21,8 @@ export const Preview: AppStory = {
// Pixel captures the complete desktop composition. Production behavior is covered by
// MessageRenderer.test.tsx because the full App can exceed the Storybook smoke-test budget.
};

export const Failures: AppStory = {
...Preview,
render: () => <AppWithMocks setup={setupSubagentFailureStory} />,
};
6 changes: 6 additions & 0 deletions src/browser/stories/App.subagentReportsPhone.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
import {
PhoneSubagentReportDecorator,
setupSubagentReportStory,
setupSubagentFailureStory,
} from "./helpers/subagentReportStory";

export default {
Expand All @@ -27,3 +28,8 @@ export const Preview: AppStory = {
},
},
};

export const Failures: AppStory = {
...Preview,
render: () => <AppWithMocks setup={setupSubagentFailureStory} />,
};
53 changes: 52 additions & 1 deletion src/browser/stories/helpers/subagentReportStory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,60 @@ export function setupSubagentReportStory() {
});
}

export function setupSubagentFailureStory() {
collapseLeftSidebar();
collapseRightSidebar();
return setupSimpleChatStory({
workspaceId: "ws-subagent-failure-presentation",
workspaceName: "subagent-failures",
projectName: "mux",
messages: [
createUserMessage("failure-user", "Have the agents review the changes and run the tests.", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 180_000,
}),
...[
{
taskId: "94f98c6165",
agentType: "exec",
errorType: "workspace_turn_superseded",
errorMessage:
"Workspace turn superseded by new input in the target workspace; the workspace continues under that input and this delegated turn will not report",
},
{
taskId: "28a75e1b09",
agentType: "explore",
errorType: "process_exit",
errorMessage: "The agent process exited unexpectedly before it could finish the review.",
},
].map((failure, index) =>
createUserMessage(
`failure-${index}`,
`<mux_subagent_failure>
<task_id>${failure.taskId}</task_id>
<execution_version>wst_f804f6a7a6:interrupted:2026-09-04T12:04:40.370Z</execution_version>
<execution_id>wst_f804f6a7a6</execution_id>
<agent_type>${failure.agentType}</agent_type>
<error_type>${failure.errorType}</error_type>
<error_message>
${failure.errorMessage}
</error_message>
This sub-agent task failed terminally and will not produce a report. Do not re-await it.
</mux_subagent_failure>`,
{
historySequence: index + 2,
timestamp: STABLE_TIMESTAMP - 120_000 + index * 60_000,
synthetic: true,
}
)
),
],
});
}

export function PhoneSubagentReportDecorator(Story: ComponentType) {
return (
<div style={{ width: 390, height: 844, overflow: "hidden" }}>
<div style={{ width: 390, maxWidth: "100%", height: 844, overflow: "hidden" }}>
<Story />
</div>
);
Expand Down
53 changes: 53 additions & 0 deletions src/common/utils/subagentFailureEnvelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { formatSubagentFailureUserMessage } from "@/node/services/taskWorkspaceSeam";
import { parseSubagentFailureEnvelope } from "./subagentFailureEnvelope";

const failure = {
childWorkspaceId: "task-123",
agentType: "exec",
errorType: "workspace_turn_superseded",
errorMessage: "New input took over.\nThe workspace continues.",
};

describe("parseSubagentFailureEnvelope", () => {
test("round-trips producer messages with each combination of optional execution metadata", () => {
for (const metadata of [
{},
{ executionId: "wst_123" },
{ executionVersion: "wst_123:interrupted:2026-09-04T12:04:40.370Z" },
{ executionId: "wst_123", executionVersion: "wst_123:failed:2026-09-04T12:04:40.370Z" },
]) {
expect(
parseSubagentFailureEnvelope(formatSubagentFailureUserMessage({ ...failure, ...metadata }))
).toEqual({
taskId: failure.childWorkspaceId,
agentType: failure.agentType,
errorType: failure.errorType,
errorMessage: failure.errorMessage,
...metadata,
});
}
});

test("preserves delimiter examples and whitespace inside error messages", () => {
const errorMessage = ` Diagnostic:\n${formatSubagentFailureUserMessage(failure)}\n trailing `;
expect(
parseSubagentFailureEnvelope(formatSubagentFailureUserMessage({ ...failure, errorMessage }))
?.errorMessage
).toBe(errorMessage);
});

test("rejects incomplete envelopes, empty required fields, and surrounding content", () => {
const valid = formatSubagentFailureUserMessage(failure);
for (const content of [
"ordinary message",
valid.replace("</error_message>", ""),
valid.replace("<task_id>task-123</task_id>", "<task_id> </task_id>"),
valid.replace(failure.errorMessage, " "),
`Before\n${valid}`,
`${valid}\nAfter`,
]) {
expect(parseSubagentFailureEnvelope(content)).toBeNull();
}
});
});
32 changes: 32 additions & 0 deletions src/common/utils/subagentFailureEnvelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface SubagentFailureEnvelope {
taskId: string;
agentType: string;
errorType: string;
errorMessage: string;
executionVersion?: string;
executionId?: string;
}

/** Parse the persisted failure protocol without changing the model-facing message. */
export function parseSubagentFailureEnvelope(content: string): SubagentFailureEnvelope | null {
// Match the entire producer envelope so malformed or mixed-content messages remain visible as-is.
// The error body is greedy: embedded protocol examples must not truncate the actual diagnostic.
const match =
/^<mux_subagent_failure>\n<task_id>([^\n<>]+)<\/task_id>\n(?:<execution_version>([^\n<>]+)<\/execution_version>\n)?(?:<execution_id>([^\n<>]+)<\/execution_id>\n)?<agent_type>([^\n<>]+)<\/agent_type>\n<error_type>([^\n<>]+)<\/error_type>\n<error_message>\n([\s\S]+)\n<\/error_message>\nThis sub-agent task failed terminally and will not produce a report\. Do not re-await it\.\n<\/mux_subagent_failure>$/.exec(
content
);
if (!match) return null;

const [, taskId, executionVersion, executionId, agentType, errorType, errorMessage] = match;
if (![taskId, agentType, errorType, errorMessage].every((field) => field.trim().length > 0)) {
return null;
}
return {
taskId,
agentType,
errorType,
errorMessage,
...(executionVersion ? { executionVersion } : {}),
...(executionId ? { executionId } : {}),
};
}
Loading