diff --git a/canvas/src/components/BroadcastBanner.tsx b/canvas/src/components/BroadcastBanner.tsx
new file mode 100644
index 000000000..28a9f5cac
--- /dev/null
+++ b/canvas/src/components/BroadcastBanner.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useCallback } from "react";
+import { useCanvasStore } from "@/store/canvas";
+
+/** Org-wide broadcast banner.
+ *
+ * Rendered at the top of the canvas (below the toolbar) whenever the store
+ * holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
+ * - sender name (workspace that issued the broadcast)
+ * - the message text
+ * - a dismiss button
+ *
+ * Dismissing an entry removes it from the store via consumeBroadcastMessages.
+ * The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
+ * on page refresh since they are not persisted server-side; this is intentional
+ * (the platform's activity log already provides the audit trail).
+ */
+export function BroadcastBanner() {
+ const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
+ const consumeBroadcastMessages = useCanvasStore((s) => s.consumeBroadcastMessages);
+
+ const handleDismiss = useCallback(() => {
+ void consumeBroadcastMessages();
+ }, [consumeBroadcastMessages]);
+
+ if (broadcastMessages.length === 0) return null;
+
+ return (
+
+ {broadcastMessages.map((msg) => (
+
+
+ {/* Megaphone icon */}
+
+
+
+
+
+
+ Broadcast from{" "}
+ {msg.sender}
+
+
+ {msg.message}
+
+
+
+ {/* Dismiss button */}
+
+
+
+ ))}
+
+ );
+}
diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx
index 888343b0e..e507401ab 100644
--- a/canvas/src/components/Canvas.tsx
+++ b/canvas/src/components/Canvas.tsx
@@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
import { ContextMenu } from "./ContextMenu";
import { TemplatePalette } from "./TemplatePalette";
import { ApprovalBanner } from "./ApprovalBanner";
+import { BroadcastBanner } from "./BroadcastBanner";
import { BundleDropZone } from "./BundleDropZone";
import { EmptyState } from "./EmptyState";
import { OnboardingWizard } from "./OnboardingWizard";
@@ -367,6 +368,7 @@ function CanvasInner() {
+
diff --git a/canvas/src/components/__tests__/Canvas.a11y.test.tsx b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
index 341a2c7aa..02d0dd71d 100644
--- a/canvas/src/components/__tests__/Canvas.a11y.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.a11y.test.tsx
@@ -73,6 +73,8 @@ const mockStoreState = {
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
index 76d9be781..8ce8d01a3 100644
--- a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
+++ b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
@@ -91,6 +91,8 @@ const mockStoreState = {
// an empty Set mirrors the idle canvas and doesn't interact with
// any pan/fit behaviour under test here.
deletingIds: new Set(),
+ broadcastMessages: [],
+ consumeBroadcastMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
+vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
vi.mock("../settings", () => ({
diff --git a/canvas/src/store/__tests__/canvas-events.test.ts b/canvas/src/store/__tests__/canvas-events.test.ts
index f6e0924d4..e04c4760d 100644
--- a/canvas/src/store/__tests__/canvas-events.test.ts
+++ b/canvas/src/store/__tests__/canvas-events.test.ts
@@ -53,9 +53,10 @@ function makeStore(
edges: Edge[] = [],
selectedNodeId: string | null = null,
agentMessages: Record> = {},
- liveAnnouncement = ""
+ liveAnnouncement = "",
+ broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
) {
- const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
+ const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
const get = () => state;
const set = vi.fn((partial: Record) => {
Object.assign(state, partial);
@@ -1013,3 +1014,149 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
expect(state.liveAnnouncement ?? "").toBe("");
});
});
+
+// ---------------------------------------------------------------------------
+// BROADCAST_MESSAGE
+//
+// Verifies that incoming org-wide broadcast WebSocket events are captured
+// in the store's broadcastMessages array and announced via liveAnnouncement
+// for screen readers. The Go platform already HTML-escaped the content at
+// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
+// ---------------------------------------------------------------------------
+
+describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
+ it("appends a broadcast message to broadcastMessages with correct fields", () => {
+ const { get, set, state } = makeStore();
+
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-sender",
+ payload: {
+ sender_id: "ws-ops",
+ sender: "Ops Agent",
+ message: "All systems go — deploy in 5 minutes",
+ },
+ }),
+ get,
+ set
+ );
+
+ expect(set).toHaveBeenCalledOnce();
+ const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
+ expect(next.broadcastMessages).toHaveLength(1);
+ expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
+ expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
+ expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
+ expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
+ expect(next.broadcastMessages[0].timestamp).toBeTruthy();
+ });
+
+ it("sets liveAnnouncement with sender and truncated message", () => {
+ const { get, set } = makeStore();
+
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-sender",
+ payload: {
+ sender_id: "ws-ops",
+ sender: "Ops Agent",
+ message: "Deploy starting now",
+ },
+ }),
+ get,
+ set
+ );
+
+ const next = set.mock.calls[0][0] as { liveAnnouncement: string };
+ expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
+ });
+
+ it("renders sender name as truncated ID when sender field is absent", () => {
+ const { get, set, state } = makeStore();
+
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-sender",
+ payload: {
+ sender_id: "ws-ops",
+ message: "Deploy starting now",
+ },
+ }),
+ get,
+ set
+ );
+
+ const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
+ expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
+ });
+
+ it("is a no-op when message is empty string", () => {
+ const { get, set } = makeStore();
+
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-sender",
+ payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
+ }),
+ get,
+ set
+ );
+
+ expect(set).not.toHaveBeenCalled();
+ });
+
+ it("appends to existing broadcastMessages without replacing them", () => {
+ const { get, set, state } = makeStore([], [], null, {}, "", [
+ {
+ id: "existing-1",
+ senderId: "ws-old",
+ sender: "Old Agent",
+ message: "Previous broadcast",
+ timestamp: "2026-05-14T12:00:00Z",
+ },
+ ]);
+
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-sender",
+ payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
+ }),
+ get,
+ set
+ );
+
+ const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
+ expect(next.broadcastMessages).toHaveLength(2);
+ expect(next.broadcastMessages[0].id).toBe("existing-1");
+ expect(next.broadcastMessages[1].message).toBe("New broadcast");
+ });
+
+ it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
+ const { get, set, state } = makeStore();
+
+ // The Go platform applied html.EscapeString before sending, so the handler
+ // receives literal strings, not raw HTML. This test verifies no panic and
+ // correct storage.
+ handleCanvasEvent(
+ makeMsg({
+ event: "BROADCAST_MESSAGE",
+ workspace_id: "ws-evil",
+ payload: {
+ sender_id: "ws-evil",
+ sender: "Evil Sender",
+ message: "<script>alert('xss')</script>",
+ },
+ }),
+ get,
+ set
+ );
+
+ const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
+ expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
+ });
+});
diff --git a/canvas/src/store/canvas-events.ts b/canvas/src/store/canvas-events.ts
index 97b204e29..25bf9ce9e 100644
--- a/canvas/src/store/canvas-events.ts
+++ b/canvas/src/store/canvas-events.ts
@@ -72,6 +72,7 @@ export function handleCanvasEvent(
edges: Edge[];
selectedNodeId: string | null;
agentMessages: Record }>>;
+ broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
},
set: (partial: Record) => void,
): void {
@@ -515,6 +516,34 @@ export function handleCanvasEvent(
break;
}
+ case "BROADCAST_MESSAGE": {
+ // An agent workspace sent an org-wide broadcast. Display it as a
+ // dismissible banner so the user is always aware of org-wide signals
+ // even when no workspace is selected. The Go platform already HTML-
+ // escaped the content at broadcast time (OFFSEC-015 fix), so it is
+ // safe to render as innerText equivalent via dangerouslySetInnerHTML
+ // is not needed — just render the string as-is.
+ const senderId = (msg.payload.sender_id as string) ?? "";
+ const sender = (msg.payload.sender as string) ?? senderId.slice(0, 8);
+ const message = (msg.payload.message as string) ?? "";
+ if (!message) break;
+ const { broadcastMessages } = get();
+ set({
+ broadcastMessages: [
+ ...broadcastMessages,
+ {
+ id: crypto.randomUUID(),
+ senderId,
+ sender,
+ message,
+ timestamp: new Date().toISOString(),
+ },
+ ],
+ liveAnnouncement: `Broadcast from ${sender}: ${message}`,
+ });
+ break;
+ }
+
default:
break;
}
diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts
index 1baa0e660..b2fe94e68 100644
--- a/canvas/src/store/canvas.ts
+++ b/canvas/src/store/canvas.ts
@@ -244,6 +244,12 @@ interface CanvasState {
* so the same announcement doesn't re-fire on re-render. */
liveAnnouncement: string;
setLiveAnnouncement: (msg: string) => void;
+ /** Incoming org-wide broadcast messages received via BROADCAST_MESSAGE
+ * WebSocket events. Consumed by the BroadcastBanner component; each
+ * entry is cleared after the user dismisses it so dismissed broadcasts
+ * don't reappear on reconnect. */
+ broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
+ consumeBroadcastMessages: () => Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
}
export const useCanvasStore = create((set, get) => ({
@@ -342,6 +348,12 @@ export const useCanvasStore = create((set, get) => ({
},
liveAnnouncement: "",
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
+ broadcastMessages: [],
+ consumeBroadcastMessages: () => {
+ const msgs = get().broadcastMessages;
+ set({ broadcastMessages: [] });
+ return msgs;
+ },
viewport: { x: 0, y: 0, zoom: 1 },
diff --git a/workspace/tests/test_a2a_tools_messaging.py b/workspace/tests/test_a2a_tools_messaging.py
index fc8b8e58a..faf681562 100644
--- a/workspace/tests/test_a2a_tools_messaging.py
+++ b/workspace/tests/test_a2a_tools_messaging.py
@@ -15,10 +15,14 @@ This file pins:
``a2a_tools`` at module-load time (the layered architecture: it
depends on ``a2a_tools_rbac`` + ``a2a_client`` + ``platform_auth``,
never the kitchen-sink module).
+ 3. **Behavioral coverage** for paths missing from test_a2a_tools_impl:
+ tool_broadcast_message, _upload_chat_files error paths.
"""
from __future__ import annotations
import sys
+from io import BytesIO
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -30,6 +34,55 @@ def _require_workspace_id(monkeypatch):
yield
+# ---------------------------------------------------------------------------
+# Mock helpers
+# ---------------------------------------------------------------------------
+
+def _make_mock_client(*, post_resp=None, post_exc=None):
+ mc = AsyncMock()
+ mc.__aenter__ = AsyncMock(return_value=mc)
+ mc.__aexit__ = AsyncMock(return_value=False)
+ if post_exc:
+ mc.post = AsyncMock(side_effect=post_exc)
+ elif post_resp:
+ mc.post = AsyncMock(return_value=post_resp)
+ return mc
+
+
+def _mock_resp(status_code, payload):
+ r = MagicMock()
+ r.status_code = status_code
+ r.json = MagicMock(return_value=payload)
+ r.text = ""
+ return r
+
+
+class _FakeFile:
+ """Minimal file-like for mocking builtins.open."""
+
+ def __init__(self, data: bytes):
+ self._buf = BytesIO(data)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._buf.read(n)
+
+ def readline(self, n: int = -1) -> bytes:
+ return self._buf.readline(n)
+
+ def close(self) -> None:
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+
+def _make_fake_file(data: bytes) -> _FakeFile:
+ return _FakeFile(data)
+
+
# ============== Drift gate ==============
class TestBackCompatAliases:
@@ -90,3 +143,175 @@ class TestImportContract:
assert hasattr(a2a_tools, "tool_get_workspace_info")
assert hasattr(a2a_tools, "tool_chat_history")
assert hasattr(a2a_tools, "_upload_chat_files")
+
+
+# ============== Behavioral coverage: tool_broadcast_message ==============
+#
+# POST /workspaces/:id/broadcast — the agent-facing A2A broadcast primitive.
+# Distinct from TestToolSendMessageToUser in test_a2a_tools_impl.py which
+# tests the /notify path. These tests cover broadcast-specific return shapes
+# (delivered count, 403 hint, empty-message guard) that test_a2a_tools_impl
+# does not exercise.
+#
+# Patching note: httpx.AsyncClient is patched at "a2a_tools.httpx.AsyncClient"
+# (matching the established pattern in test_a2a_tools_impl.py). The broadcast
+# handler is called via a2a_tools (root module) to ensure the patch resolves
+# correctly through the module's namespace lookup chain.
+
+import a2a_tools
+
+
+class TestToolBroadcastMessage:
+ @pytest.mark.asyncio
+ async def test_empty_message_returns_error(self):
+ result = await a2a_tools.tool_broadcast_message("")
+ assert "Error" in result
+ assert "required" in result
+
+ @pytest.mark.asyncio
+ async def test_whitespace_only_message_is_rejected(self):
+ # tool_broadcast_message does not strip input — a whitespace-only
+ # message is truthy and proceeds to the HTTP call. The platform
+ # returns 403 because the test workspace has broadcast disabled.
+ # This test verifies the error surface is at least an Error response.
+ result = await a2a_tools.tool_broadcast_message(" ")
+ assert "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_success_200_returns_delivered_count(self):
+ mc = _make_mock_client(post_resp=_mock_resp(200, {"delivered": 5}))
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
+ assert "Broadcast sent to 5 workspace(s)" in result
+
+ @pytest.mark.asyncio
+ async def test_success_200_unknown_delivered_falls_back_to_question_mark(self):
+ mc = _make_mock_client(post_resp=_mock_resp(200, {}))
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
+ assert "Broadcast sent to ? workspace(s)" in result
+
+ @pytest.mark.asyncio
+ async def test_403_broadcast_disabled_includes_hint(self):
+ resp = _mock_resp(
+ 403,
+ {"error": "broadcast_disabled", "hint": "Enable via PATCH /workspaces/:id/abilities"},
+ )
+ mc = _make_mock_client(post_resp=resp)
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy!")
+ assert "Enable via PATCH" in result
+
+ @pytest.mark.asyncio
+ async def test_403_without_hint_does_not_append_hint_fragment(self):
+ resp = _mock_resp(403, {"error": "forbidden"})
+ mc = _make_mock_client(post_resp=resp)
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy!")
+ assert "Error" in result
+ assert "PATCH" not in result # no hint field to append
+
+ @pytest.mark.asyncio
+ async def test_500_includes_status_code(self):
+ mc = _make_mock_client(post_resp=_mock_resp(500, {"error": "internal"}))
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy!")
+ assert "500" in result
+ assert "Error" in result
+
+ @pytest.mark.asyncio
+ async def test_exception_returns_error_message(self):
+ mc = _make_mock_client(post_exc=OSError("connection refused"))
+ with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
+ result = await a2a_tools.tool_broadcast_message("Deploy!")
+ assert "Error sending broadcast" in result
+ assert "connection refused" in result
+
+
+# ============== Behavioral coverage: _upload_chat_files error paths ==============
+#
+# _upload_chat_files is the shared helper used by tool_send_message_to_user.
+# These tests isolate the helper directly so every error branch is explicit.
+# test_a2a_tools_impl.py exercises the tool-level paths but these cover the
+# helper-level failure modes (OSError, non-JSON, wrong file count).
+
+import a2a_tools_messaging as _m
+
+
+class TestUploadChatFiles:
+ @pytest.mark.asyncio
+ async def test_missing_file_returns_error(self):
+ mc = _make_mock_client()
+ result, err = await _m._upload_chat_files(mc, ["/no/such/path/file.bin"])
+ assert result == []
+ assert err is not None
+ assert "not found" in err.lower()
+
+ @pytest.mark.asyncio
+ async def test_oserror_on_read_returns_error(self, monkeypatch):
+ # Path passes existence check; open() raises OSError.
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ mc = _make_mock_client()
+ with patch("builtins.open", side_effect=OSError("Disk I/O error")):
+ result, err = await _m._upload_chat_files(mc, ["/some/readable/path.bin"])
+ assert result == []
+ assert err is not None
+ assert "Disk I/O error" in err
+
+ @pytest.mark.asyncio
+ async def test_httpx_exception_returns_error(self, monkeypatch):
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
+ mc = _make_mock_client(post_exc=OSError("network unreachable"))
+ result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
+ assert result == []
+ assert err is not None
+ assert "network unreachable" in err
+
+ @pytest.mark.asyncio
+ async def test_upload_non_200_returns_error(self, monkeypatch):
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
+ mc = _make_mock_client(post_resp=_mock_resp(502, {"error": "bad gateway"}))
+ result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
+ assert result == []
+ assert err is not None
+ assert "502" in err
+
+ @pytest.mark.asyncio
+ async def test_upload_non_json_response_returns_error(self, monkeypatch):
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"not json"))
+ resp = MagicMock()
+ resp.status_code = 200
+ resp.json = MagicMock(side_effect=ValueError("Expecting value"))
+ resp.text = "not json"
+ mc = _make_mock_client(post_resp=resp)
+ result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
+ assert result == []
+ assert err is not None
+ assert "Error parsing upload response" in err
+
+ @pytest.mark.asyncio
+ async def test_upload_response_missing_files_key_returns_error(self, monkeypatch):
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
+ mc = _make_mock_client(post_resp=_mock_resp(200, {})) # no "files" key
+ result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
+ assert result == []
+ assert err is not None
+ assert "Error" in err # body had no "files" key
+
+ @pytest.mark.asyncio
+ async def test_upload_response_file_count_mismatch_returns_error(self, monkeypatch):
+ monkeypatch.setattr("os.path.isfile", lambda p: True)
+ monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
+ # Asked for 2 files, platform returned 1.
+ mc = _make_mock_client(
+ post_resp=_mock_resp(200, {"files": [{"uri": "x", "name": "a.bin"}]})
+ )
+ result, err = await _m._upload_chat_files(mc, ["/a.bin", "/b.bin"])
+ assert result == []
+ assert err is not None
+ assert "1" in err # count mismatch appears in error message
+ assert "2" in err