diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index e5bf32afd5..36e52ffab4 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -959,6 +959,7 @@ describe("ProjectSidebar flat chat list", () => { ({ selectedWorkspace: null, setSelectedWorkspace: () => undefined, + archivingWorkspaceIds: new Set(), preflightArchiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "ready" } }), archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), @@ -1010,6 +1011,7 @@ describe("ProjectSidebar flat chat list", () => { ({ selectedWorkspace: null, setSelectedWorkspace: () => undefined, + archivingWorkspaceIds: new Set(), preflightArchiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "ready" } }), archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), @@ -1100,6 +1102,7 @@ describe("ProjectSidebar flat chat list", () => { ({ selectedWorkspace: null, setSelectedWorkspace: () => undefined, + archivingWorkspaceIds: new Set(), preflightArchiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "ready" } }), archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), @@ -1280,6 +1283,7 @@ describe("ProjectSidebar flat chat list", () => { ({ selectedWorkspace: null, setSelectedWorkspace: () => undefined, + archivingWorkspaceIds: new Set(), preflightArchiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "ready" } }), archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), @@ -1335,6 +1339,7 @@ describe("ProjectSidebar flat chat list", () => { ({ selectedWorkspace: null, setSelectedWorkspace: () => undefined, + archivingWorkspaceIds: new Set(), preflightArchiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "ready" } }), archiveWorkspace: () => Promise.resolve({ success: true, data: { kind: "archived" } }), diff --git a/src/browser/features/Messages/MessageRenderer.stories.tsx b/src/browser/features/Messages/MessageRenderer.stories.tsx index bbdc303e17..ffb65fcfc1 100644 --- a/src/browser/features/Messages/MessageRenderer.stories.tsx +++ b/src/browser/features/Messages/MessageRenderer.stories.tsx @@ -212,10 +212,10 @@ The report inherited transcript-sized markdown styles instead of compact task ch title: "Agent update", reportMarkdown: `## Agent update -The same compact report typography applies to incremental agent findings. +Incremental findings read as outgoing communication, separate from compact task details. -- Body and inline \`code\` remain aligned with tool chrome. -- Headings retain a modest hierarchy.`, +- Body and inline \`code\` stay legible alongside transcript messages. +- Headings retain a clear hierarchy.`, }, { success: true } ), @@ -238,7 +238,7 @@ The same compact report typography applies to incremental agent findings. const agentReportCard = await waitFor(() => { const card = canvasElement.querySelector( - '[data-component="AgentReportToolCall"]' + '[data-component="AgentCommunicationCard"]' ); if (!card) throw new Error("Agent report card not rendered"); return card; @@ -287,20 +287,21 @@ The same compact report typography applies to incremental agent findings. }); await waitFor(() => { - const report = agentReportCard.querySelector(".compact-report-markdown"); + const report = agentReportCard.querySelector(".markdown-content"); const heading = report?.querySelector("h2"); const code = report?.querySelector("code"); if (!report || !heading || !code) { throw new Error("Expanded agent report markdown not rendered"); } - if (Number.parseFloat(getComputedStyle(report).fontSize) > 11) { - throw new Error("Agent report body text is larger than compact tool chrome"); + const bodyFontSize = Number.parseFloat(getComputedStyle(report).fontSize); + if (bodyFontSize < 14) { + throw new Error("Agent report body text is smaller than transcript prose"); } - if (Number.parseFloat(getComputedStyle(heading).fontSize) > 13) { - throw new Error("Agent report heading is too large for compact tool chrome"); + if (Number.parseFloat(getComputedStyle(heading).fontSize) <= bodyFontSize) { + throw new Error("Agent report heading has lost its hierarchy"); } - if (Number.parseFloat(getComputedStyle(code).fontSize) > 11) { - throw new Error("Agent report inline code is larger than compact tool chrome"); + if (Number.parseFloat(getComputedStyle(code).fontSize) < 12) { + throw new Error("Agent report inline code is too small to read"); } if (agentReportCard.scrollWidth > agentReportCard.clientWidth) { throw new Error( diff --git a/src/browser/features/Tools/AgentReportToolCall.test.tsx b/src/browser/features/Tools/AgentReportToolCall.test.tsx index d0a3dbe2dd..8d9ee7969f 100644 --- a/src/browser/features/Tools/AgentReportToolCall.test.tsx +++ b/src/browser/features/Tools/AgentReportToolCall.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; -import { cleanup, render } from "@testing-library/react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; import { AgentReportToolCall } from "./AgentReportToolCall"; @@ -77,4 +77,184 @@ describe("AgentReportToolCall", () => { expect(view.getByText(/Report file: report\.md/)).toBeTruthy(); }); + + test("collapses the body while retaining the title, then reopens it", () => { + const view = render( + + + + ); + const toggle = view.getByRole("button", { name: "Recovery audit" }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(view.queryByText("Detailed findings")).toBeNull(); + fireEvent.click(toggle); + expect(view.getByText(/Detailed findings/)).toBeTruthy(); + }); + + test("keeps validation failures visible when collapsed instead of claiming delivery", () => { + const view = render( + + + + ); + fireEvent.click(view.getByRole("button", { name: "Audit" })); + expect(view.getByRole("alert").textContent).toContain("Report exceeds limit"); + expect(view.getByRole("status").className).toContain("text-danger"); + }); + + test("prefers the submitted report over the draft arguments", () => { + const view = render( + + + + ); + expect(view.queryByText("Draft report")).toBeNull(); + expect(view.getByText("Accepted report")).toBeTruthy(); + }); + + test.each(["", " "])("keeps the report toggle named for a blank title: %j", (title) => { + const view = render( + + + + ); + expect(view.getByRole("button", { name: /\S/ })).toBeTruthy(); + }); + + test.each( + ["legacy output", 42, true, [], { success: false, message: "Missing errors" }].map( + (result) => ({ result }) + ) + )("keeps malformed persisted report results renderable: %j", ({ result }) => { + const view = render( + + + + ); + expect(view.getByText("Preserved findings")).toBeTruthy(); + expect(view.getByRole("status").className).not.toContain("text-success"); + }); + + test("accepts report results decorated by post hooks without mutating hook output", () => { + const result = Object.freeze({ + success: true, + report: { reportMarkdown: "Submitted findings" }, + hook_output: "Formatter completed", + hook_duration_ms: 20, + hook_path: ".xum/tool_post", + ui_only: {}, + }); + const view = render( + + + + ); + expect(view.getByText("Submitted findings")).toBeTruthy(); + expect(view.getByRole("status").className).toContain("text-success"); + }); + + test("shows bare pre-hook blocking errors", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Blocked by project hook"); + expect(view.getByRole("status").className).toContain("text-danger"); + }); + + test.each([null, undefined, { type: "json", value: null }].map((result) => ({ result })))( + "does not claim delivery for a missing completed result: %j", + ({ result }) => { + const view = render( + + + + ); + expect(view.getByRole("status").className).not.toContain("text-success"); + expect(view.getByRole("status").textContent).toBe("Result unavailable"); + } + ); + + test.each([ + { status: "pending", label: "Pending" }, + { status: "executing", label: "Sending…" }, + { status: "failed", label: "Not sent" }, + { status: "interrupted", label: "Interrupted" }, + ] as const)("preserves lifecycle status without a result: $status", ({ status, label }) => { + const view = render( + + + + ); + expect(view.getByRole("status").textContent).toBe(label); + }); + + test("accepts SDK-wrapped results with inner and outer hook metadata", () => { + const view = render( + + + + ); + expect(view.getByRole("status").className).toContain("text-success"); + }); + + test("shows SDK-wrapped blocking errors", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Wrapped blocking error"); + }); }); diff --git a/src/browser/features/Tools/AgentReportToolCall.tsx b/src/browser/features/Tools/AgentReportToolCall.tsx index 2e692e47cc..fef7d65f65 100644 --- a/src/browser/features/Tools/AgentReportToolCall.tsx +++ b/src/browser/features/Tools/AgentReportToolCall.tsx @@ -1,21 +1,13 @@ import React from "react"; import type { AgentReportToolArgs, AgentReportToolResult } from "@/common/types/tools"; +import { AgentReportToolResultSchema } from "@/common/utils/tools/toolDefinitions"; +import { ErrorBox } from "./Shared/ToolPrimitives"; +import { AgentCommunicationCard } from "./Shared/AgentCommunicationCard"; import { - ToolContainer, - ToolHeader, - ExpandIcon, - ToolName, - StatusIndicator, - ToolDetails, - ToolIcon, - ErrorBox, -} from "./Shared/ToolPrimitives"; -import { - useToolExpansion, - getStatusDisplay, isToolErrorResult, + normalizeToolResultForRendering, type ToolStatus, } from "./Shared/toolUtils"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; @@ -30,7 +22,7 @@ type AgentReportRenderableArgs = AgentReportToolArgs | LegacyAgentReportFileArgs interface AgentReportToolCallProps { args: AgentReportRenderableArgs; - result?: AgentReportToolResult; + result?: unknown; status?: ToolStatus; } @@ -47,42 +39,49 @@ function getSubmittedReportMarkdown( return `Report file: ${args.reportMarkdownPath ?? "report.md"}`; } -export const AgentReportToolCall: React.FC = ({ - args, - result, - status = "pending", -}) => { - // Default to expanded so incremental findings are visible when they wake the parent. - const { expanded, toggleExpanded } = useToolExpansion(true); - - const errorResult = isToolErrorResult(result) ? result : null; - - const title = args.title ?? "Agent update"; - const reportMarkdown = getSubmittedReportMarkdown(args, result); - - // Show a small preview when collapsed so the card still has some useful context. - const firstLine = reportMarkdown.trim().split("\n")[0] ?? ""; - const preview = firstLine.length > 80 ? firstLine.slice(0, 80).trim() + "…" : firstLine; +export const AgentReportToolCall: React.FC = (props) => { + // Persisted results bypass input-schema validation and may be malformed. + const normalizedResult = normalizeToolResultForRendering(props.result); + const parsed = AgentReportToolResultSchema.safeParse(normalizedResult); + const result = isToolErrorResult(normalizedResult) + ? normalizedResult + : parsed.success + ? parsed.data + : undefined; + const invalidResult = (props.result != null || props.status === "completed") && result == null; + const reportMarkdown = getSubmittedReportMarkdown(props.args, result); + const failedResult = result?.success === false ? result : null; + const title = props.args.title?.trim() ?? ""; return ( - - - - - {title} - {getStatusDisplay(status)} - - - {expanded && ( - - - {errorResult && {errorResult.error}} - - )} - - {!expanded && preview && ( -
{preview}
- )} -
+ 0 ? title : "Agent update"} + destination="To parent" + status={failedResult || invalidResult ? "failed" : (props.status ?? "pending")} + statusLabel={invalidResult ? "Result unavailable" : undefined} + preview={reportMarkdown} + initiallyExpanded + error={ + failedResult && ( + + {isToolErrorResult(failedResult) ? ( + failedResult.error + ) : ( + <> + {failedResult.message} + {failedResult.errors.map((error, index) => ( +
+ {error.path}: {error.message} +
+ ))} + + )} +
+ ) + } + > + +
); }; diff --git a/src/browser/features/Tools/Shared/AgentCommunicationCard.tsx b/src/browser/features/Tools/Shared/AgentCommunicationCard.tsx new file mode 100644 index 0000000000..25479fb712 --- /dev/null +++ b/src/browser/features/Tools/Shared/AgentCommunicationCard.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from "react"; +import { ChevronRight, CircleAlert, CircleCheck, Clock3 } from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import { ToolChrome, ToolContainer, ToolDetails, ToolIcon } from "./ToolPrimitives"; +import { useToolExpansion, type ToolStatus } from "./toolUtils"; + +interface AgentCommunicationCardProps { + toolName: "agent_report" | "task_send_message"; + title: string; + destination: ReactNode; + status: ToolStatus; + statusLabel?: string; + preview: string; + initiallyExpanded: boolean; + children: ReactNode; + error?: ReactNode; +} + +const DELIVERY_LABELS: Record = { + pending: "Pending", + executing: "Sending…", + completed: "Sent", + failed: "Not sent", + interrupted: "Interrupted", + backgrounded: "Queued", + redacted: "Redacted", +}; + +/** Outgoing agent communication should read like the received report, not a command log. */ +export function AgentCommunicationCard(props: AgentCommunicationCardProps) { + const { expanded, toggleExpanded } = useToolExpansion(props.initiallyExpanded); + const StatusIcon = + props.status === "completed" + ? CircleCheck + : props.status === "failed" || props.status === "interrupted" + ? CircleAlert + : Clock3; + + return ( + + + +
+ {props.destination} + + + +
+
+ {expanded ? ( + + {props.children} + + ) : ( + + {props.preview.trim().split("\n")[0]} + + )} + {props.error} +
+ ); +} diff --git a/src/browser/features/Tools/Shared/toolUtils.tsx b/src/browser/features/Tools/Shared/toolUtils.tsx index 885ba00637..61b5587565 100644 --- a/src/browser/features/Tools/Shared/toolUtils.tsx +++ b/src/browser/features/Tools/Shared/toolUtils.tsx @@ -1,6 +1,7 @@ import React from "react"; import { AlertTriangle, Check, CircleDot, EyeOff, X } from "lucide-react"; import type { ToolErrorResult } from "@/common/types/tools"; +import { isPlainObject } from "@/common/utils/isPlainObject"; import { useStickyExpand, type UseStickyExpandOptions, @@ -165,6 +166,22 @@ export function unwrapResult(result: unknown): unknown { return result; } +/** Preserve wrapper compatibility before strict result validation without mutating hook/UI output. */ +export function normalizeToolResultForRendering(result: unknown): unknown { + const unwrapped = unwrapResult(result); + if (!isPlainObject(unwrapped)) return unwrapped; + const core = { ...unwrapped }; + delete core.hook_output; + delete core.hook_duration_ms; + delete core.hook_path; + delete core.ui_only; + // Blocking pre-hooks return a bare error instead of the tool's result schema. + if (typeof core.error === "string" && !("success" in core) && !("status" in core)) { + return { success: false, error: core.error }; + } + return core; +} + /** * Type guard for ToolErrorResult shape: { success: false, error: string }. * Use this when you need type narrowing to access error. diff --git a/src/browser/features/Tools/TaskToolCall.test.tsx b/src/browser/features/Tools/TaskToolCall.test.tsx index 4bc799d27f..74052f3bfc 100644 --- a/src/browser/features/Tools/TaskToolCall.test.tsx +++ b/src/browser/features/Tools/TaskToolCall.test.tsx @@ -750,11 +750,226 @@ describe("TaskSendMessageToolCall", () => { ); - expect(view.getByText("queued")).toBeDefined(); - fireEvent.click(view.getByText("task_send_message")); + expect(view.getByRole("status").textContent).toBe("Queued"); + fireEvent.click(view.getByRole("button", { name: "Message to agent" })); expect(view.getByText("child-task")).toBeDefined(); expect(view.getByText("Use the corrected API shape.")).toBeDefined(); }); + + test.each([ + { status: "accepted", taskId: "child-task", targetRelation: "ancestor" }, + { status: "reactivated", taskId: "child-task" }, + { status: "queued", taskId: "child-task", targetRelation: "sibling" }, + { status: "not_found", taskId: "child-task" }, + { status: "invalid_scope", taskId: "child-task" }, + { status: "not_active", taskId: "child-task", taskStatus: "reported", error: "Inactive peer" }, + { status: "error", taskId: "child-task", error: "Delivery failed" }, + { status: "refused", taskId: "child-task", reason: "Message already queued" }, + { status: "rate_limited", taskId: "child-task", retryAfterMs: 1200 }, + ] as const)("uses the delivery outcome instead of generic tool completion: $status", (result) => { + const view = render( + + + + ); + const status = view.getByRole("status"); + const sent = result.status === "accepted" || result.status === "reactivated"; + expect(status.className.includes("text-success")).toBe(sent); + expect(status.className.includes("text-danger")).toBe(!sent && result.status !== "queued"); + if (result.error != null) expect(view.getByRole("alert").textContent).toBe(result.error); + if (result.reason != null) expect(view.getByRole("alert").textContent).toBe(result.reason); + if (result.targetRelation != null) + expect(view.getByText(`To ${result.targetRelation}`)).toBeTruthy(); + if (result.retryAfterMs != null) expect(view.getByText("Retry in 2s")).toBeTruthy(); + }); + + test("shows transport failures even while collapsed", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Connection lost"); + expect( + view.getByRole("button", { name: "Message to agent" }).getAttribute("aria-expanded") + ).toBe("false"); + }); + + test("opens the recipient workspace without toggling the message", () => { + const workspace = createWorkspaceMetadata({ id: "child-task", title: "Reviewer" }); + const select = mock(() => undefined); + workspaceContextMock = { + workspaceMetadata: new Map([[workspace.id, workspace]]), + setSelectedWorkspace: select, + }; + try { + const view = render( + + + + ); + fireEvent.click(view.getByRole("button", { name: "child-task" })); + expect(select).toHaveBeenCalledWith(workspace); + expect( + view.getByRole("button", { name: "Message to Reviewer" }).getAttribute("aria-expanded") + ).toBe("false"); + } finally { + workspaceContextMock = null; + } + }); + + test.each( + [ + "legacy output", + 42, + true, + [], + { status: "refused", reason: {} }, + { status: "constructor" }, + ].map((result) => ({ result })) + )("keeps malformed persisted message results renderable: %j", ({ result }) => { + const view = render( + + + + ); + expect(view.getByRole("status").className).not.toContain("text-success"); + fireEvent.click(view.getByRole("button", { name: "Message to agent" })); + expect(view.getByText(taskSendMessageArgs.message)).toBeTruthy(); + }); + + test("does not render unvalidated fields carried beside a transport error", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Connection lost"); + }); + + test.each(["accepted", "queued", "reactivated"] as const)( + "preserves hooked delivery outcome: %s", + (status) => { + const view = render( + + + + ); + expect(view.getByRole("status").className).toContain( + status === "queued" ? "text-backgrounded" : "text-success" + ); + } + ); + + test("shows bare pre-hook errors alongside their metadata", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Blocked by project hook"); + expect(view.getByRole("status").className).toContain("text-danger"); + }); + + test.each([null, undefined, { type: "json", value: null }].map((result) => ({ result })))( + "does not claim delivery for a missing completed result: %j", + ({ result }) => { + const view = render( + + + + ); + expect(view.getByRole("status").className).not.toContain("text-success"); + expect(view.getByRole("status").textContent).toBe("Result unavailable"); + } + ); + + test.each([ + { status: "pending", label: "Pending" }, + { status: "executing", label: "Sending…" }, + { status: "failed", label: "Not sent" }, + { status: "interrupted", label: "Interrupted" }, + ] as const)("preserves lifecycle status without a result: $status", ({ status, label }) => { + const view = render( + + + + ); + expect(view.getByRole("status").textContent).toBe(label); + }); + + test("accepts SDK-wrapped results with inner and outer hook metadata", () => { + const view = render( + + + + ); + expect(view.getByRole("status").className).toContain("text-success"); + }); + + test("shows SDK-wrapped blocking errors", () => { + const view = render( + + + + ); + expect(view.getByRole("alert").textContent).toBe("Wrapped blocking error"); + }); }); const taskTerminateArgs = { task_ids: ["wfr_x"] }; diff --git a/src/browser/features/Tools/TaskToolCall.tsx b/src/browser/features/Tools/TaskToolCall.tsx index 90bc362ab7..45ba395fc3 100644 --- a/src/browser/features/Tools/TaskToolCall.tsx +++ b/src/browser/features/Tools/TaskToolCall.tsx @@ -15,8 +15,11 @@ import { useToolExpansion, getStatusDisplay, isToolErrorResult, + normalizeToolResultForRendering, type ToolStatus, } from "./Shared/toolUtils"; +import { AgentCommunicationCard } from "./Shared/AgentCommunicationCard"; +import { TaskSendMessageToolResultSchema } from "@/common/utils/tools/toolDefinitions"; import { MarkdownRenderer } from "../Messages/MarkdownRenderer"; import { useOptionalMessageListContext } from "../Messages/MessageListContext"; import { useStickyExpand } from "../Messages/useStickyExpand"; @@ -1706,53 +1709,88 @@ const TaskListItem: React.FC<{ interface TaskSendMessageToolCallProps { args: TaskSendMessageToolArgs; - result?: TaskSendMessageToolSuccessResult; + result?: unknown; status?: ToolStatus; } +const MESSAGE_DELIVERY: Record< + TaskSendMessageToolSuccessResult["status"], + { status: ToolStatus; label: string } +> = { + accepted: { status: "completed", label: "Accepted" }, + queued: { status: "backgrounded", label: "Queued" }, + reactivated: { status: "completed", label: "Sent · Agent reactivated" }, + not_found: { status: "failed", label: "Target not found" }, + invalid_scope: { status: "failed", label: "Invalid target" }, + not_active: { status: "failed", label: "Agent inactive" }, + error: { status: "failed", label: "Not sent" }, + refused: { status: "failed", label: "Refused" }, + rate_limited: { status: "failed", label: "Rate limited" }, +}; + export const TaskSendMessageToolCall: React.FC = (props) => { - const { expanded, toggleExpanded } = useToolExpansion(false); - const status = props.status ?? "pending"; - const summary = props.result?.status ?? "sending"; + const workspaceContext = useOptionalWorkspaceContext(); + const workspace = findWorkspaceForTaskTarget( + workspaceContext?.workspaceMetadata, + props.args.task_id + ); + // Persisted output is unknown even when the tool arguments have passed validation. + const normalizedResult = normalizeToolResultForRendering(props.result); + const parsed = TaskSendMessageToolResultSchema.safeParse(normalizedResult); + const result = parsed.success ? parsed.data : undefined; + const toolError = isToolErrorResult(normalizedResult) ? normalizedResult : undefined; + const invalidResult = + (props.result != null || props.status === "completed") && result == null && toolError == null; + // A finished tool call can still mean delivery was refused or queued, not sent. + const delivery = result ? MESSAGE_DELIVERY[result.status] : undefined; + const relation = result && "targetRelation" in result ? result.targetRelation : undefined; + const error = toolError?.error ?? (result && "error" in result ? result.error : undefined); return ( - - - - - task_send_message - {summary} - {getStatusDisplay(status)} - - - {expanded && ( - -
-
- - {props.result && } - {props.result && "targetRelation" in props.result && props.result.targetRelation && ( - to {props.result.targetRelation} - )} -
-
- {props.args.message} + + To {relation && `${relation} `} + + + } + status={ + toolError || invalidResult ? "failed" : (delivery?.status ?? props.status ?? "pending") + } + statusLabel={invalidResult ? "Result unavailable" : delivery?.label} + preview={props.args.message} + initiallyExpanded={false} + error={ + <> + {error && ( + + {error} + + )} + {result?.status === "refused" && ( + + {result.reason} + + )} + {result?.status === "rate_limited" && result.retryAfterMs != null && ( +
+ Retry in {Math.ceil(result.retryAfterMs / 1000)}s
- {props.result && "error" in props.result && props.result.error && ( -
{props.result.error}
- )} - {props.result?.status === "refused" && ( -
{props.result.reason}
- )} - {props.result?.status === "rate_limited" && props.result.retryAfterMs != null && ( -
- Retry in {Math.ceil(props.result.retryAfterMs / 1000)}s -
- )} -
- - )} - + )} + + } + > +
+ {props.args.message} +
+ ); }; diff --git a/src/browser/stories/App.agentCommunication.stories.tsx b/src/browser/stories/App.agentCommunication.stories.tsx new file mode 100644 index 0000000000..6b287ae3b3 --- /dev/null +++ b/src/browser/stories/App.agentCommunication.stories.tsx @@ -0,0 +1,168 @@ +import { expect, userEvent, waitFor, within } from "@storybook/test"; +import { getAutoExpandPrefsKey } from "@/common/constants/storage"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { setupSimpleChatStory } from "./helpers/chatSetup"; +import { PhoneSubagentReportDecorator } from "./helpers/subagentReportStory"; +import { collapseLeftSidebar, collapseRightSidebar } from "./helpers/uiState"; +import { createAssistantMessage } from "./mocks/messages"; +import { createWorkspace, STABLE_TIMESTAMP } from "./mocks/workspaces"; + +const WORKSPACE_ID = "ws-agent-communication"; +const REPORT_TITLE = "Preserving active-pointer crash recovery"; +const REPORT = + "One final generation-guard audit caught an edge not covered by existing tests: startup’s authoritative newer-handle selection must be allowed to replace a stale **active** child pointer, not only terminal pointers.\n\nI’m adding a compare-with-snapshotted previous execution ID permission only for startup reconciliation, plus a test for the old running status."; +const MESSAGE = + "Focused follow-up before finalizing: verify the native canvas clipping behavior and the parent popup-blocked failure path.\n\nCheck handshake ordering in src/browser/features/Tools/SubagentTranscriptDialog.tsx and keep the change scoped to the existing behavior."; + +function setupCommunicationStory(failed = false, longMessage = false) { + // Separate transcript stores so switching stories cannot retain a successful delivery. + const workspaceId = `${WORKSPACE_ID}-${failed ? "failed" : longMessage ? "long" : "sent"}`; + collapseLeftSidebar(); + collapseRightSidebar(); + updatePersistedState(getAutoExpandPrefsKey(workspaceId), {}); + return setupSimpleChatStory({ + workspaceId, + workspaceName: "agent-communication", + projectName: "mux", + messages: [ + createAssistantMessage("outgoing-updates", "", { + historySequence: 1, + timestamp: STABLE_TIMESTAMP, + toolCalls: [ + { + type: "dynamic-tool", + toolCallId: "outgoing-report", + toolName: "agent_report", + state: "output-available", + input: { title: REPORT_TITLE, reportMarkdown: REPORT }, + output: failed + ? { success: false, error: "The parent workspace is unavailable." } + : { success: true }, + }, + { + type: "dynamic-tool", + toolCallId: "outgoing-message", + toolName: "task_send_message", + state: "output-available", + input: { + task_id: "b3947e259a", + message: longMessage + ? Array.from({ length: 200 }, (_, index) => `Check ${index + 1}: ${MESSAGE}`).join( + "\n\n" + ) + : MESSAGE, + }, + output: failed + ? { + status: "refused", + taskId: "b3947e259a", + reason: "This message is already queued.", + } + : { status: "reactivated", taskId: "b3947e259a" }, + }, + ], + }), + ], + additionalWorkspaces: [ + createWorkspace({ + id: "b3947e259a", + name: "reviewer", + title: "Reviewer", + projectName: "mux", + parentWorkspaceId: workspaceId, + taskStatus: "running", + }), + ], + }); +} + +export default { + ...appMeta, + title: "App/AgentCommunication", +}; + +export const Outgoing: AppStory = { + render: () => , + parameters: { + ...appMeta.parameters, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["laptop"] } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("button", { name: "Message to Reviewer" }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + // Prove keyboard collapse/expand as well as pointer interaction. + toggle.focus(); + await userEvent.keyboard("{Enter}"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.keyboard(" "); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); + }, +}; + +export const Phone: AppStory = { + ...Outgoing, + // Pin both the manager/Pixel viewport and the test-runner's otherwise desktop-width canvas. + globals: { viewport: { value: "mobile1", isRotated: false } }, + decorators: [PhoneSubagentReportDecorator], + parameters: { + ...appMeta.parameters, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone"] } }, + }, + play: async (context) => { + await Outgoing.play?.(context); + const cards = context.canvasElement.querySelectorAll( + '[data-component="AgentCommunicationCard"]' + ); + await expect(cards.length).toBe(2); + for (const card of cards) { + await expect(card.clientWidth).toBeLessThan(390); + await expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth); + } + }, +}; + +export const DeliveryFailures: AppStory = { + render: () => setupCommunicationStory(true)} />, + parameters: { + ...appMeta.parameters, + pixel: { matrix: { themes: ["dark", "light"], viewports: ["laptop"] } }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findAllByRole("alert")).toHaveLength(2); + const toggle = canvas.getByRole("button", { name: REPORT_TITLE }); + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(canvas.getAllByRole("alert")).toHaveLength(2); + }, +}; + +export const LongMessage: AppStory = { + ...Outgoing, + render: () => setupCommunicationStory(false, true)} />, + play: async (context) => { + await Outgoing.play?.(context); + const content = within(context.canvasElement).getByRole("region", { name: "Message content" }); + await expect(content.scrollHeight).toBeGreaterThan(content.clientHeight); + await expect(content.clientHeight).toBeLessThanOrEqual(window.innerHeight * 0.4 + 1); + content.focus(); + await expect(content).toHaveFocus(); + content.scrollTo({ top: content.scrollHeight }); + await waitFor(() => expect(content.scrollTop).toBeGreaterThan(0)); + await expect(content.scrollWidth).toBeLessThanOrEqual(content.clientWidth); + }, +}; + +export const LongMessagePhone: AppStory = { + ...LongMessage, + globals: Phone.globals, + decorators: Phone.decorators, + parameters: Phone.parameters, +}; 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;