From 099c5e408edc8a1d756c75d36c95411e84b6e31e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:15:26 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20remove=20obsolete?= =?UTF-8?q?=20Config=20mock=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the stale getSessionDir mock from the cross-project pin-order fixture to restore the existing typecheck gate. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$12.80`_ --- src/node/services/workspaceService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 12e71b83f3..3cf746ef27 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -13733,7 +13733,6 @@ describe("WorkspaceService reorderPinned across projects", () => { const mockConfig: Partial = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), findWorkspace: mock((id: string) => { const found = findEntry(id); if (!found) return null; From a54391265ad4633f267a22e97030d8623e1b24c1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:15:27 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20render=20readable=20s?= =?UTF-8?q?ubagent=20failure=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace synthetic failure XML with compact cards, neutral superseded-turn messaging, and collapsible diagnostics while preserving raw protocol data. Add parser, rendering, and responsive story coverage. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$12.80`_ --- .../Messages/MessageRenderer.test.tsx | 94 +++++++++++++++++++ .../SubagentFailureMessageContent.tsx | 63 +++++++++++++ src/browser/features/Messages/UserMessage.tsx | 16 +++- .../App.subagentReportsDesktop.stories.tsx | 7 +- .../App.subagentReportsPhone.stories.tsx | 6 ++ .../stories/helpers/subagentReportStory.tsx | 53 ++++++++++- .../utils/subagentFailureEnvelope.test.ts | 53 +++++++++++ src/common/utils/subagentFailureEnvelope.ts | 32 +++++++ 8 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 src/browser/features/Messages/SubagentFailureMessageContent.tsx create mode 100644 src/common/utils/subagentFailureEnvelope.test.ts create mode 100644 src/common/utils/subagentFailureEnvelope.ts diff --git a/src/browser/features/Messages/MessageRenderer.test.tsx b/src/browser/features/Messages/MessageRenderer.test.tsx index 28872c9bf5..666fe5343e 100644 --- a/src/browser/features/Messages/MessageRenderer.test.tsx +++ b/src/browser/features/Messages/MessageRenderer.test.tsx @@ -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: ` +task-failed +wst_123:interrupted:2026-09-04T12:04:40.370Z +wst_123 +exec +${errorType} + +${errorMessage} + +This sub-agent task failed terminally and will not produce a report. Do not re-await it. +`, + }; + } + + test("distinguishes superseded turns from failures and collapses diagnostic metadata", () => { + const message = failureMessage("workspace_turn_superseded", "New input superseded this turn."); + const view = render( + + + + ); + 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 = '\nWorker exited unexpectedly.'; + const message = failureMessage("unknown_future_error", error); + message.content = message.content.replace(/[^\n]*\n/g, ""); + const view = render( + + + + ); + 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("", "") }, + { ...valid, content: `${valid.content}\nAdditional context must not be lost.` }, + ]) { + const view = render( + + + + ); + 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; diff --git a/src/browser/features/Messages/SubagentFailureMessageContent.tsx b/src/browser/features/Messages/SubagentFailureMessageContent.tsx new file mode 100644 index 0000000000..b92df1d01c --- /dev/null +++ b/src/browser/features/Messages/SubagentFailureMessageContent.tsx @@ -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 ( +
+
+ ); +} diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index 285438beaa..e8f3fd5d70 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -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"; @@ -88,6 +90,7 @@ export const UserMessage: React.FC = ({ // 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; @@ -260,6 +263,13 @@ export const UserMessage: React.FC = ({ {isInProgress ? "subagent update" : "subagent report"} ); + } else if (subagentFailure) { + label = ( + + + ); } else if (isSynthetic) { label = ( @@ -269,8 +279,8 @@ export const UserMessage: React.FC = ({ } 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" ); @@ -286,6 +296,8 @@ export const UserMessage: React.FC = ({ ); } else if (subagentReport) { renderedContent = ; + } else if (subagentFailure) { + renderedContent = ; } else { renderedContent = ( , +}; diff --git a/src/browser/stories/App.subagentReportsPhone.stories.tsx b/src/browser/stories/App.subagentReportsPhone.stories.tsx index a711dc5d08..762bb9ffcf 100644 --- a/src/browser/stories/App.subagentReportsPhone.stories.tsx +++ b/src/browser/stories/App.subagentReportsPhone.stories.tsx @@ -4,6 +4,7 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { PhoneSubagentReportDecorator, setupSubagentReportStory, + setupSubagentFailureStory, } from "./helpers/subagentReportStory"; export default { @@ -27,3 +28,8 @@ export const Preview: AppStory = { }, }, }; + +export const Failures: AppStory = { + ...Preview, + render: () => , +}; diff --git a/src/browser/stories/helpers/subagentReportStory.tsx b/src/browser/stories/helpers/subagentReportStory.tsx index dcda5fe204..ccb6f26711 100644 --- a/src/browser/stories/helpers/subagentReportStory.tsx +++ b/src/browser/stories/helpers/subagentReportStory.tsx @@ -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}`, + ` +${failure.taskId} +wst_f804f6a7a6:interrupted:2026-09-04T12:04:40.370Z +wst_f804f6a7a6 +${failure.agentType} +${failure.errorType} + +${failure.errorMessage} + +This sub-agent task failed terminally and will not produce a report. Do not re-await it. +`, + { + historySequence: index + 2, + timestamp: STABLE_TIMESTAMP - 120_000 + index * 60_000, + synthetic: true, + } + ) + ), + ], + }); +} + export function PhoneSubagentReportDecorator(Story: ComponentType) { return ( -
+
); diff --git a/src/common/utils/subagentFailureEnvelope.test.ts b/src/common/utils/subagentFailureEnvelope.test.ts new file mode 100644 index 0000000000..ce5d36bd2f --- /dev/null +++ b/src/common/utils/subagentFailureEnvelope.test.ts @@ -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("", ""), + valid.replace("task-123", " "), + valid.replace(failure.errorMessage, " "), + `Before\n${valid}`, + `${valid}\nAfter`, + ]) { + expect(parseSubagentFailureEnvelope(content)).toBeNull(); + } + }); +}); diff --git a/src/common/utils/subagentFailureEnvelope.ts b/src/common/utils/subagentFailureEnvelope.ts new file mode 100644 index 0000000000..2f1b1f3c21 --- /dev/null +++ b/src/common/utils/subagentFailureEnvelope.ts @@ -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 = + /^\n([^\n<>]+)<\/task_id>\n(?:([^\n<>]+)<\/execution_version>\n)?(?:([^\n<>]+)<\/execution_id>\n)?([^\n<>]+)<\/agent_type>\n([^\n<>]+)<\/error_type>\n\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 } : {}), + }; +} From d60229677f7f37097948424df42a7e6f30239946 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 4 Sep 2026 13:35:15 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20supply=20archive=20?= =?UTF-8?q?state=20in=20flat=20sidebar=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repair five existing fixtures that omit the required archivingWorkspaceIds field and fail before reaching their assertions. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$12.80`_ --- .../components/ProjectSidebar/ProjectSidebar.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index e5bf32afd5..ee96b62512 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -957,6 +957,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1008,6 +1009,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1098,6 +1100,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1278,6 +1281,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () => @@ -1333,6 +1337,7 @@ describe("ProjectSidebar flat chat list", () => { spyOn(WorkspaceContextModule, "useWorkspaceActions").mockImplementation( () => ({ + archivingWorkspaceIds: new Set(), selectedWorkspace: null, setSelectedWorkspace: () => undefined, preflightArchiveWorkspace: () =>