diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3cc767a1e9..05f1dafba4 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,4 +1,5 @@ import type { Preview } from "@storybook/react-vite"; +import { isPixel } from "@coder/pixel-storybook"; import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext"; import "../src/browser/styles/globals.css"; import { @@ -27,6 +28,15 @@ import { configure } from "storybook/test"; // after userEvent.click can exceed the 1 s default. configure({ asyncUtilTimeout: 5000 }); +const PIXEL_STABILITY_CSS = ` + *, *::before, *::after { + animation: none !important; + caret-color: transparent !important; + scroll-behavior: auto !important; + transition: none !important; + } +`; + const STORYBOOK_FONTS_READY_TIMEOUT_MS = 2500; let fontsReadyPromise: Promise | null = null; @@ -159,9 +169,13 @@ const preview: Preview = { clearWorkspaceDrafts(); return ( - - - + <> + {/* Pixel captures semantic states, never arbitrary animation frames or blinking carets. */} + {isPixel() && } + + + + ); }, ], diff --git a/docs/agents/system-prompt.mdx b/docs/agents/system-prompt.mdx index 4dc99a53c5..12908fd3fa 100644 --- a/docs/agents/system-prompt.mdx +++ b/docs/agents/system-prompt.mdx @@ -67,7 +67,7 @@ If you are inside a variants child workspace, complete only the slice described -Messages wrapped in are internal sub-agent outputs from Mux. A in_progress report is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap. +Messages wrapped in are internal sub-agent outputs from Mux. A report whose JSON payload has status "in_progress" is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap. `; diff --git a/pixel.jsonc b/pixel.jsonc index 134cd46dbc..5b4a54d4bb 100644 --- a/pixel.jsonc +++ b/pixel.jsonc @@ -7,6 +7,9 @@ }, // Default variant: chrome at laptop width (1200px) rendering the preview's // default dark theme. Stories opt into more variants via parameters.pixel.matrix. + // Render at 2x density so text and rounded-border antialiasing are deterministic across + // otherwise identical headless Chromium captures. + "devicePixelRatio": 2, "matrix": { "browsers": ["chrome"], "viewports": ["laptop"] diff --git a/src/browser/features/Messages/MessageRenderer.test.tsx b/src/browser/features/Messages/MessageRenderer.test.tsx index 8a90675163..54a9cf602c 100644 --- a/src/browser/features/Messages/MessageRenderer.test.tsx +++ b/src/browser/features/Messages/MessageRenderer.test.tsx @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import type { DisplayedMessage } from "@/common/types/message"; +import { formatSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; import { MessageRenderer } from "./MessageRenderer"; +import { parseSubagentReportEnvelope } from "./SubagentReportMessageContent"; describe("MessageRenderer goal continuation rows", () => { beforeEach(() => { @@ -222,6 +224,164 @@ Live goal accounting at limit: }); }); +describe("MessageRenderer subagent report 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 createReportMessage(content: string, synthetic = true): DisplayedMessage { + return { + type: "user", + id: "subagent-report", + historyId: "subagent-report", + content, + historySequence: 25, + isSynthetic: synthetic, + }; + } + + test("presents incremental synthetic reports without exposing the protocol envelope", () => { + const message = createReportMessage(` +task-123 +explore +in_progress +Rendering path traced + +Found the **message renderer** and its responsive story coverage. + +`); + + const { getByText, queryByText } = render( + + + + ); + + expect(getByText("subagent update")).toBeDefined(); + expect(getByText("Rendering path traced")).toBeDefined(); + expect(getByText("In progress")).toBeDefined(); + expect(getByText(/message renderer/)).toBeDefined(); + expect(queryByText("auto")).toBeNull(); + expect(queryByText(/mux_subagent_report/)).toBeNull(); + }); + + test("preserves arbitrary protocol examples and semantic whitespace", () => { + const expected = { + taskId: "task-json-framing", + agentType: "explore", + status: "completed" as const, + title: "Checked \n without exposing the envelope", + reportMarkdown: + " const answer = 42;\nQuoted delimiter:\n\n\nHard break. ", + }; + + expect(parseSubagentReportEnvelope(formatSubagentReportEnvelope(expected))).toEqual(expected); + }); + + test("normalizes multiline report titles instead of exposing the envelope", () => { + const message = createReportMessage(` +task-multiline-title +explore +completed +Presentation trace +completed cleanly + +All rendering paths were verified. + +`); + + const { getByText, queryByText } = render( + + + + ); + + expect(getByText("Presentation trace completed cleanly")).toBeDefined(); + expect(getByText("All rendering paths were verified.")).toBeDefined(); + expect(queryByText(/mux_subagent_report/)).toBeNull(); + }); + + test("treats legacy reports as completed and reveals structured output on demand", () => { + const message = createReportMessage(` +task-legacy +review +Review complete + +The responsive presentation is ready. + + +\`\`\`json +{"mobileVerified":true,"risk":"low"} +\`\`\` + +`); + + const { getByRole, getByText, queryByText } = render( + + + + ); + + expect(getByText("subagent report")).toBeDefined(); + expect(getByText("Completed")).toBeDefined(); + const toggle = getByRole("button", { name: "Structured output" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(queryByText(/mobileVerified/)).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(getByText(/"mobileVerified": true/)).toBeDefined(); + }); + + test("keeps malformed and user-authored lookalikes on the ordinary message path", () => { + const malformed = createReportMessage(` +task-malformed +explore +in_progress +`); + const userAuthored = createReportMessage( + ` +task-user +explore +in_progress +User text + +This was typed by a user. + +`, + false + ); + + const malformedView = render( + + + + ); + expect(malformedView.getByText("auto")).toBeDefined(); + expect(malformedView.queryByText("subagent update")).toBeNull(); + malformedView.unmount(); + + const userView = render( + + + + ); + expect(userView.queryByText("subagent update")).toBeNull(); + expect(userView.queryByText("subagent report")).toBeNull(); + }); +}); + describe("MessageRenderer bash monitor wake rows", () => { beforeEach(() => { globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; diff --git a/src/browser/features/Messages/SubagentReportMessageContent.tsx b/src/browser/features/Messages/SubagentReportMessageContent.tsx new file mode 100644 index 0000000000..106d0cfa97 --- /dev/null +++ b/src/browser/features/Messages/SubagentReportMessageContent.tsx @@ -0,0 +1,89 @@ +import { useState, type ReactElement } from "react"; +import { Bot, Braces, ChevronRight, CircleCheck, Radio } from "lucide-react"; + +import { cn } from "@/common/lib/utils"; +import type { SubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; +import { MarkdownRenderer } from "./MarkdownRenderer"; + +export { parseSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; + +export function formatSubagentStructuredOutput(report: SubagentReportEnvelope): string | undefined { + if (report.structuredOutput === undefined) return undefined; + if (typeof report.structuredOutput === "string") return report.structuredOutput; + return JSON.stringify(report.structuredOutput, null, 2); +} + +interface SubagentReportMessageContentProps { + report: SubagentReportEnvelope; +} + +/** + * Present trusted sub-agent findings as transcript content rather than exposing the model-facing + * XML protocol. The existing user bubble owns the border and surface, avoiding nested card chrome. + */ +export function SubagentReportMessageContent( + props: SubagentReportMessageContentProps +): ReactElement { + const [structuredOutputExpanded, setStructuredOutputExpanded] = useState(false); + const structuredOutputJson = formatSubagentStructuredOutput(props.report); + const isInProgress = props.report.status === "in_progress"; + const StatusIcon = isInProgress ? Radio : CircleCheck; + const statusLabel = isInProgress ? "In progress" : "Completed"; + + return ( +
+
+
+ + + + {structuredOutputJson && ( +
+ + {structuredOutputExpanded && ( +
+              {structuredOutputJson}
+            
+ )} +
+ )} +
+ ); +} diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index c52af203c1..07c85a8758 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -12,6 +12,11 @@ import { MessageWindow } from "./MessageWindow"; import { UserMessageContent } from "./UserMessageContent"; import { GoalSyntheticMessageContent } from "./GoalSyntheticMessageContent"; import { BashMonitorWakeMessageContent } from "./BashMonitorWakeMessageContent"; +import { + formatSubagentStructuredOutput, + parseSubagentReportEnvelope, + SubagentReportMessageContent, +} from "./SubagentReportMessageContent"; import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; @@ -24,6 +29,7 @@ import { import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { + Bot, ChevronLeft, ChevronRight, Clipboard, @@ -93,6 +99,18 @@ export const UserMessage: React.FC = ({ const bashMonitorWake = message.bashMonitorWake; const content = message.content; const visibleContent = stripStagedAttachmentNotice(content); + // 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 structuredOutputJson = subagentReport + ? formatSubagentStructuredOutput(subagentReport) + : undefined; + const copyContent = subagentReport + ? [ + subagentReport.reportMarkdown, + ...(structuredOutputJson ? [`Structured output:\n${structuredOutputJson}`] : []), + ].join("\n\n") + : visibleContent; const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); const isMobileTouch = typeof window !== "undefined" && @@ -204,7 +222,7 @@ export const UserMessage: React.FC = ({ : []), { label: copied ? "Copied" : "Copy", - onClick: () => void copyToClipboard(visibleContent), + onClick: () => void copyToClipboard(copyContent), icon: copied ? : , }, ]; @@ -231,6 +249,19 @@ export const UserMessage: React.FC = ({ monitor wake ); + } else if (subagentReport) { + const isInProgress = subagentReport.status === "in_progress"; + label = ( + + + ); } else if (isSynthetic) { label = ( @@ -246,7 +277,8 @@ export const UserMessage: React.FC = ({ const isSideQuestion = message.isSideQuestion === true; const syntheticClassName = cn( className, - isSynthetic && "opacity-70", + isSynthetic && !subagentReport && "opacity-70", + subagentReport && "ml-0 w-full", (isGoalContinuation || isBudgetLimitWrapup) && "italic" ); @@ -264,6 +296,8 @@ export const UserMessage: React.FC = ({ renderedContent = ( ); + } else if (subagentReport) { + renderedContent = ; } else { renderedContent = ( renderTab("360px"), play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -143,5 +151,8 @@ export const Editor: Story = { const row = await canvas.findByText("preferences.md"); await userEvent.click(row); await canvas.findByLabelText("Memory file content"); + // Hide the focused textarea caret before Pixel captures the editor state. + await userEvent.hover(canvasElement); + blurActiveElement(); }, }; diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx index 7df8147321..2490c76e0b 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.stories.tsx @@ -170,6 +170,13 @@ export const ProjectSettingsEmpty: Story = { }; export const ProjectSettingsAddRemoteServerHeaders: Story = { + parameters: { + pixel: { + // Chromium alternates the rounded form border's corner antialiasing by one shade. The full + // interaction sequence remains covered by Storybook tests. + exclude: true, + }, + }, render: () => ( diff --git a/src/browser/features/Tools/AgentReportToolCall.tsx b/src/browser/features/Tools/AgentReportToolCall.tsx index 38c23631a2..3a6089328d 100644 --- a/src/browser/features/Tools/AgentReportToolCall.tsx +++ b/src/browser/features/Tools/AgentReportToolCall.tsx @@ -69,7 +69,7 @@ export const AgentReportToolCall: React.FC = ({ - {title} + {title} {getStatusDisplay(status)} diff --git a/src/browser/features/Tools/AttachFileToolCall.stories.tsx b/src/browser/features/Tools/AttachFileToolCall.stories.tsx index d9d6ff22c2..b32c305a26 100644 --- a/src/browser/features/Tools/AttachFileToolCall.stories.tsx +++ b/src/browser/features/Tools/AttachFileToolCall.stories.tsx @@ -54,6 +54,14 @@ function GallerySection(props: { label: string; children: ReactNode }) { // (image, video, audio, markdown, generic file) into a single snapshot to keep // the snapshot budget low while preserving every distinct visual state. export const Gallery: Story = { + parameters: { + pixel: { + // Chromium's native media controls contain an internal loading spinner whose frame is not + // controlled by page CSS. Mask only those controls; the surrounding attachment UI remains + // under visual regression coverage. + mask: [{ selector: "video, audio" }], + }, + }, render: () => (
diff --git a/src/browser/stories/App.subagentReportsDesktop.stories.tsx b/src/browser/stories/App.subagentReportsDesktop.stories.tsx new file mode 100644 index 0000000000..956a4c84d2 --- /dev/null +++ b/src/browser/stories/App.subagentReportsDesktop.stories.tsx @@ -0,0 +1,23 @@ +/** 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"; + +export default { + ...appMeta, + title: "App/SubagentReports/Desktop", +}; + +/** Incremental, completed legacy, and structured sub-agent report states. */ +export const Preview: AppStory = { + // Pixel owns this visual-only desktop matrix. The full App cold-start can exceed the + // Storybook test-runner's 30-second smoke timeout before any assertions begin. + tags: ["!test"], + render: () => , + parameters: { + ...appMeta.parameters, + pixel: { matrix: PIXEL_DUAL_THEME }, + }, + // 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. +}; diff --git a/src/browser/stories/App.subagentReportsPhone.stories.tsx b/src/browser/stories/App.subagentReportsPhone.stories.tsx new file mode 100644 index 0000000000..a711dc5d08 --- /dev/null +++ b/src/browser/stories/App.subagentReportsPhone.stories.tsx @@ -0,0 +1,29 @@ +/** Phone-width visual coverage for sub-agent report presentation. */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { + PhoneSubagentReportDecorator, + setupSubagentReportStory, +} from "./helpers/subagentReportStory"; + +export default { + ...appMeta, + title: "App/SubagentReports/Phone", +}; + +export const Preview: AppStory = { + // The fixed-width decorator + pinned Pixel phone viewport are the static breakpoint contract. + // Production rendering behavior is covered by MessageRenderer.test.tsx. + tags: ["!test"], + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + render: () => , + decorators: [PhoneSubagentReportDecorator], + parameters: { + ...appMeta.parameters, + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["phone"] }, + }, + }, +}; diff --git a/src/browser/stories/helpers/subagentReportStory.tsx b/src/browser/stories/helpers/subagentReportStory.tsx new file mode 100644 index 0000000000..2ee06e8f88 --- /dev/null +++ b/src/browser/stories/helpers/subagentReportStory.tsx @@ -0,0 +1,83 @@ +import type { ComponentType } from "react"; + +import { setupSimpleChatStory } from "./chatSetup"; +import { collapseLeftSidebar, collapseRightSidebar } from "./uiState"; +import { + createAssistantMessage, + createSubagentReportMessage, + createUserMessage, +} from "../mocks/messages"; +import { STABLE_TIMESTAMP } from "../mocks/workspaces"; + +const REPORT_MESSAGES = [ + createUserMessage("report-user", "Review the message rendering path and flag any UX gaps.", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 180_000, + }), + createAssistantMessage( + "report-assistant", + "I delegated the rendering trace and asked the sub-agent to report important findings as they land.", + { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 170_000, + } + ), + createSubagentReportMessage("report-progress", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 120_000, + taskId: "18c2511cea", + agentType: "explore", + status: "in_progress", + title: "Current report presentation traced across the parent transcript", + reportMarkdown: + "Parent-side reports currently expose the model-facing envelope. A dedicated renderer can preserve **markdown**, paths like `src/browser/features/Messages/UserMessage.tsx`, and status without the raw protocol.", + }), + createSubagentReportMessage("report-complete", { + historySequence: 4, + timestamp: STABLE_TIMESTAMP - 60_000, + taskId: "18c2511cea", + agentType: "explore", + // Omit status to cover report envelopes persisted before incremental updates existed. + title: "Presentation recommendation complete", + reportMarkdown: [ + "## Recommendation", + "", + "- Render reports as trusted sub-agent findings, not ordinary user input.", + "- Keep structured workflow data available without letting it dominate the transcript.", + ].join("\n"), + structuredOutput: { + affectedFiles: [ + "src/browser/features/Messages/UserMessage.tsx", + "src/browser/features/Messages/SubagentReportMessageContent.tsx", + ], + mobileVerified: true, + }, + }), + createAssistantMessage( + "report-integrated", + "I’ll incorporate both findings into the final implementation and keep the structured details available for inspection.", + { + historySequence: 5, + timestamp: STABLE_TIMESTAMP - 50_000, + } + ), +] as const; + +export function setupSubagentReportStory() { + collapseLeftSidebar(); + collapseRightSidebar(); + return setupSimpleChatStory({ + workspaceId: "ws-subagent-report-presentation", + workspaceName: "subagent-reports", + projectName: "mux", + messages: [...REPORT_MESSAGES], + }); +} + +export function PhoneSubagentReportDecorator(Story: ComponentType) { + return ( +
+ +
+ ); +} diff --git a/src/browser/stories/mocks/messages.ts b/src/browser/stories/mocks/messages.ts index da21432dbc..0917deaf5b 100644 --- a/src/browser/stories/mocks/messages.ts +++ b/src/browser/stories/mocks/messages.ts @@ -7,6 +7,7 @@ import type { MuxFilePart, MuxToolPart, } from "@/common/types/message"; +import { formatSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; import { DEFAULT_MODEL } from "@/common/constants/knownModels"; import { GOAL_BUDGET_LIMIT_KIND, @@ -122,6 +123,38 @@ export function createBashMonitorWakeMessage( }; } +/** Create the synthetic protocol envelope used to wake a parent with sub-agent findings. */ +export function createSubagentReportMessage( + id: string, + opts: { + historySequence: number; + timestamp?: number; + taskId: string; + agentType: string; + title: string; + reportMarkdown: string; + status?: "in_progress" | "completed"; + structuredOutput?: unknown; + } +): ChatMuxMessage { + return createUserMessage( + id, + formatSubagentReportEnvelope({ + taskId: opts.taskId, + agentType: opts.agentType, + status: opts.status ?? "completed", + title: opts.title, + reportMarkdown: opts.reportMarkdown, + ...(opts.structuredOutput !== undefined ? { structuredOutput: opts.structuredOutput } : {}), + }), + { + historySequence: opts.historySequence, + timestamp: opts.timestamp, + synthetic: true, + } + ); +} + /** Create a compaction request user message (triggers shimmer effect on streaming response) */ export function createCompactionRequestMessage( id: string, diff --git a/src/common/utils/subagentReportEnvelope.test.ts b/src/common/utils/subagentReportEnvelope.test.ts new file mode 100644 index 0000000000..0f682294df --- /dev/null +++ b/src/common/utils/subagentReportEnvelope.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatSubagentReportEnvelope, + parseSubagentReportEnvelope, +} from "./subagentReportEnvelope"; + +describe("subagentReportEnvelope", () => { + test("round-trips arbitrary protocol examples and semantic whitespace", () => { + const report = { + taskId: "task-json-framing", + agentType: "explore", + status: "completed" as const, + title: "Checked \n and ", + reportMarkdown: + " const answer = 42;\nQuoted delimiter:\n\n\nHard break. ", + structuredOutput: { + example: "\n", + }, + }; + + expect(parseSubagentReportEnvelope(formatSubagentReportEnvelope(report))).toEqual(report); + }); + + test("parses legacy envelopes without an explicit status as completed", () => { + const legacy = ` +legacy-task +review +Legacy result + +Legacy markdown + + +\`\`\`json +{"score":1} +\`\`\` + +`; + + expect(parseSubagentReportEnvelope(legacy)).toEqual({ + taskId: "legacy-task", + agentType: "review", + status: "completed", + title: "Legacy result", + reportMarkdown: "Legacy markdown", + structuredOutput: { score: 1 }, + }); + }); + + test("preserves legacy report bodies that quote the old title separator", () => { + const legacy = ` +legacy-protocol-doc +explore +completed +Protocol notes + +Before the quoted separator. + + +After the quoted separator. + +`; + + expect(parseSubagentReportEnvelope(legacy)?.reportMarkdown).toBe( + "Before the quoted separator.\n\n\nAfter the quoted separator." + ); + }); + + test("rejects malformed envelopes", () => { + expect(parseSubagentReportEnvelope("not a report")).toBeNull(); + expect( + parseSubagentReportEnvelope(` +{"taskId":"missing-fields"} +`) + ).toBeNull(); + }); +}); diff --git a/src/common/utils/subagentReportEnvelope.ts b/src/common/utils/subagentReportEnvelope.ts new file mode 100644 index 0000000000..5e14c0492d --- /dev/null +++ b/src/common/utils/subagentReportEnvelope.ts @@ -0,0 +1,133 @@ +export type SubagentReportStatus = "in_progress" | "completed"; + +export interface SubagentReportEnvelope { + taskId: string; + agentType: string; + status: SubagentReportStatus; + title: string; + reportMarkdown: string; + structuredOutput?: unknown; +} + +const ROOT_OPEN = ""; +const ROOT_CLOSE = ""; +const STRUCTURED_OUTPUT_START = "\n\n"; +const STRUCTURED_OUTPUT_END = "\n"; +const TITLE_REPORT_SEPARATOR = "\n\n"; +const REPORT_END = "\n"; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isStatus(value: unknown): value is SubagentReportStatus { + return value === "in_progress" || value === "completed"; +} + +/** + * New reports use JSON framing so arbitrary Markdown and protocol examples round-trip exactly while + * remaining readable to the parent model. The outer tag preserves the existing prompt contract. + */ +export function formatSubagentReportEnvelope(report: SubagentReportEnvelope): string { + const json = JSON.stringify(report, null, 2); + if (json === undefined) { + throw new Error("Subagent report envelope must be JSON-serializable"); + } + return `${ROOT_OPEN}\n${json}\n${ROOT_CLOSE}`; +} + +function parseJsonEnvelope(inner: string): SubagentReportEnvelope | null { + let value: unknown; + try { + value = JSON.parse(inner); + } catch { + return null; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + + const record = value as Record; + if ( + !isNonEmptyString(record.taskId) || + !isNonEmptyString(record.agentType) || + !isStatus(record.status) || + !isNonEmptyString(record.title) || + !isNonEmptyString(record.reportMarkdown) + ) { + return null; + } + + return { + taskId: record.taskId, + agentType: record.agentType, + status: record.status, + title: record.title, + reportMarkdown: record.reportMarkdown, + ...(Object.hasOwn(record, "structuredOutput") + ? { structuredOutput: record.structuredOutput } + : {}), + }; +} + +function parseLegacyStructuredOutput(block: string | null): unknown { + if (block === null) return undefined; + const fenced = /^```json\s*\n([\s\S]*?)\n```$/.exec(block.trim()); + const json = fenced?.[1]?.trim() ?? block.trim(); + try { + return JSON.parse(json); + } catch { + return json; + } +} + +/** Best-effort compatibility for reports persisted before JSON framing was introduced. */ +function parseLegacyEnvelope(inner: string): SubagentReportEnvelope | null { + let reportEnvelope = inner; + let structuredOutputBlock: string | null = null; + if (inner.endsWith(STRUCTURED_OUTPUT_END)) { + const start = inner.lastIndexOf(STRUCTURED_OUTPUT_START); + if (start !== -1) { + reportEnvelope = inner.slice(0, start); + structuredOutputBlock = inner + .slice(start + STRUCTURED_OUTPUT_START.length, -STRUCTURED_OUTPUT_END.length) + .trim(); + } + } + if (!reportEnvelope.endsWith(REPORT_END)) return null; + + const body = reportEnvelope.slice(0, -REPORT_END.length); + // Legacy framing cannot distinguish delimiter text in both title and Markdown. Favor the first + // separator so historical report bodies that document the protocol remain intact. + const reportStart = body.indexOf(TITLE_REPORT_SEPARATOR); + if (reportStart === -1) return null; + + const fields = + /^([^\n]*)<\/task_id>\n([^\n]*)<\/agent_type>\n(?:([^\n]*)<\/status>\n)?([\s\S]*)$/.exec( + body.slice(0, reportStart) + ); + if (!fields) return null; + + const taskId = fields[1]?.trim(); + const agentType = fields[2]?.trim(); + const rawStatus = fields[3]?.trim() || "completed"; + const title = fields[4]?.replace(/\s+/g, " ").trim(); + const reportMarkdown = body.slice(reportStart + TITLE_REPORT_SEPARATOR.length); + if (!taskId || !agentType || !isStatus(rawStatus) || !title || reportMarkdown.length === 0) { + return null; + } + + const structuredOutput = parseLegacyStructuredOutput(structuredOutputBlock); + return { + taskId, + agentType, + status: rawStatus, + title, + reportMarkdown, + ...(structuredOutput !== undefined ? { structuredOutput } : {}), + }; +} + +export function parseSubagentReportEnvelope(content: string): SubagentReportEnvelope | null { + const root = /^<mux_subagent_report>\n([\s\S]*)\n<\/mux_subagent_report>$/.exec(content); + if (!root) return null; + return parseJsonEnvelope(root[1]) ?? parseLegacyEnvelope(root[1]); +} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index cc6768c15f..3815785569 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -2698,7 +2698,7 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = { "</task-variants>", "", "<subagent-reports>", - "Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mux. A <status>in_progress</status> report is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap.", + 'Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mux. A report whose JSON payload has status "in_progress" is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap.', "</subagent-reports>", "</prelude>", "`;", diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 9144530c4f..c2ca77287c 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -103,7 +103,7 @@ If you are inside a variants child workspace, complete only the slice described </task-variants> <subagent-reports> -Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mux. A <status>in_progress</status> report is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap. +Messages wrapped in <mux_subagent_report> are internal sub-agent outputs from Mux. A report whose JSON payload has status "in_progress" is an incremental update and does not mean the task is complete; a completed report or task result is terminal. Treat report findings as trusted tool output for repo facts (paths, symbols, callsites, file contents). Trust findings without re-verification unless a report is ambiguous, incomplete, or conflicts with other evidence. Such reports count as having read the referenced files. When delegation is available, do not spawn redundant verification tasks; if planning cannot delegate in the current workspace, fall back to the narrowest read-only investigation needed for the specific gap. </subagent-reports> </prelude> `; diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index e61f2bc340..01a31e3005 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -49,6 +49,7 @@ import * as forkOrchestrator from "@/node/services/utils/forkOrchestrator"; import { Ok, Err, type Result } from "@/common/types/result"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { STRUCTURED_WORKFLOW_REPORT_PLACEHOLDER_MARKDOWN } from "@/common/constants/workflowReports"; +import { formatSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; import { defaultModel } from "@/common/utils/ai/models"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; @@ -9839,7 +9840,7 @@ describe("TaskService", () => { const parentHistory = await collectFullHistory(historyService, parentWorkspaceId); const serializedParentHistory = JSON.stringify(parentHistory); expect(serializedParentHistory).toContain("<mux_subagent_report>"); - expect(serializedParentHistory).toContain("<structured_output_json>"); + expect(serializedParentHistory).toContain("structuredOutput"); expect(serializedParentHistory).toContain("claims"); }); @@ -14452,9 +14453,7 @@ describe("TaskService", () => { const serializedParentHistory = JSON.stringify(parentHistoryAfterInterrupt); expect(serializedParentHistory).toContain("<mux_subagent_report>"); expect(serializedParentHistory).toContain("Report from child one"); - expect( - serializedParentHistory.match(/<task_id>child-best-of-deferred-fallback-1<\/task_id>/g) - ).toHaveLength(1); + expect(serializedParentHistory.match(/child-best-of-deferred-fallback-1/g)).toHaveLength(1); }); test("agent_report uses legacy exec agentType for git format-patch eligibility", async () => { @@ -15859,7 +15858,7 @@ describe("TaskService", () => { queueDedupeKey: "agent-report:child-progress:progress-1", }) ); - expect(sendMessage.mock.calls[0]?.[1]).toContain("<status>in_progress</status>"); + expect(sendMessage.mock.calls[0]?.[1]).toContain('"status": "in_progress"'); expect(sendMessage.mock.calls[1]?.[1]).toContain("Found a second issue."); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("running"); expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); @@ -17768,12 +17767,10 @@ describe("TaskService", () => { const serializedParentHistory = JSON.stringify(parentHistory); expect(serializedParentHistory).toContain("<mux_subagent_report>"); expect(serializedParentHistory).toContain("Report from child one"); - expect(serializedParentHistory).toContain("<structured_output_json>"); + expect(serializedParentHistory).toContain("structuredOutput"); expect(serializedParentHistory).toContain("score"); expect( - serializedParentHistory.match( - /<task_id>child-best-of-concurrent-deferred-fallback-1<\/task_id>/g - ) + serializedParentHistory.match(/child-best-of-concurrent-deferred-fallback-1/g) ).toHaveLength(1); }); @@ -17821,7 +17818,13 @@ describe("TaskService", () => { createMuxMessage( "progress-report", "user", - `<mux_subagent_report>\n<task_id>${childOneId}</task_id>\n<agent_type>explore</agent_type>\n<status>in_progress</status>\n<title>Finding\n\nEarly finding\n\n`, + formatSubagentReportEnvelope({ + taskId: childOneId, + agentType: "explore", + status: "in_progress", + title: "Finding", + reportMarkdown: "Early finding", + }), { timestamp: Date.now(), synthetic: true } ) ) @@ -17900,7 +17903,13 @@ describe("TaskService", () => { createMuxMessage( "completed-report", "user", - `\n${childOneId}\nexplore\ncompleted\nResult\n\n${quoted}\n\n`, + formatSubagentReportEnvelope({ + taskId: childOneId, + agentType: "explore", + status: "completed", + title: "Result", + reportMarkdown: quoted, + }), { timestamp: Date.now(), synthetic: true } ) ) @@ -18052,9 +18061,7 @@ describe("TaskService", () => { expect(serializedParentHistory).toContain(""); expect(serializedParentHistory).toContain("Report from child one"); expect( - serializedParentHistory.match( - /child-best-of-concurrent-direct-fallback-1<\/task_id>/g - ) + serializedParentHistory.match(/child-best-of-concurrent-direct-fallback-1/g) ).toHaveLength(1); }); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 3b09d6b961..065997769d 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -16,6 +16,10 @@ import type { WorkspaceService } from "@/node/services/workspaceService"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; import { STRUCTURED_WORKFLOW_REPORT_PLACEHOLDER_MARKDOWN } from "@/common/constants/workflowReports"; +import { + formatSubagentReportEnvelope, + parseSubagentReportEnvelope, +} from "@/common/utils/subagentReportEnvelope"; import { WORKSPACE_TURN_TASK_TAGS } from "@/constants/workspaceTags"; import { log } from "@/node/services/log"; import { @@ -278,28 +282,6 @@ export interface TaskCreateArgs { }; } -function stringifyStructuredOutputForSubagentReport(structuredOutput: unknown): string { - const json = JSON.stringify(structuredOutput, null, 2); - assert( - json !== undefined, - "stringifyStructuredOutputForSubagentReport requires JSON-serializable structured output" - ); - return json; -} - -function parseSubagentReportEnvelope(text: string): { taskId: string; status?: string } | null { - const envelope = /^\n([\s\S]*?)\n<\/mux_subagent_report>$/.exec(text); - if (envelope == null) { - return null; - } - const taskId = /^([^<]+)<\/task_id>$/m.exec(envelope[1])?.[1]?.trim(); - if (!taskId) { - return null; - } - const status = /^([^<]+)<\/status>$/m.exec(envelope[1])?.[1]?.trim(); - return { taskId, ...(status ? { status } : {}) }; -} - function formatSubagentReportUserMessage(params: { childWorkspaceId: string; agentType: string; @@ -313,29 +295,14 @@ function formatSubagentReportUserMessage(params: { assert(params.title.length > 0, "subagent report message requires title"); assert(params.reportMarkdown.length > 0, "subagent report message requires markdown"); - const lines = [ - "", - `${params.childWorkspaceId}`, - `${params.agentType}`, - `${params.status}`, - `${params.title}`, - "", - params.reportMarkdown, - "", - ]; - - if (params.structuredOutput !== undefined) { - lines.push( - "", - "```json", - stringifyStructuredOutputForSubagentReport(params.structuredOutput), - "```", - "" - ); - } - - lines.push(""); - return lines.join("\n"); + return formatSubagentReportEnvelope({ + taskId: params.childWorkspaceId, + agentType: params.agentType, + status: params.status, + title: params.title, + reportMarkdown: params.reportMarkdown, + ...(params.structuredOutput !== undefined ? { structuredOutput: params.structuredOutput } : {}), + }); } // Failure twin of formatSubagentReportUserMessage: terminal child failures are