From d4713a0725650d78a95c86b01be0eb7aef61c1cb Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Fri, 15 May 2026 09:39:37 +0000 Subject: [PATCH] feat(canvas): wire BROADCAST_MESSAGE WebSocket event to a dismissible banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1156: add canvas handling for BROADCAST_MESSAGE WebSocket events and add behavioral tests for broadcast_message and talk_to_user. Changes: - canvas/src/store/canvas.ts: add broadcastMessages state field and consumeBroadcastMessages() method to CanvasState interface - canvas/src/store/canvas-events.ts: add case BROADCAST_MESSAGE handler that appends to broadcastMessages[] and sets liveAnnouncement for screen readers - canvas/src/components/BroadcastBanner.tsx: new component rendering a dismissible blue banner (megaphone icon, sender name, message, dismiss button) — follows the same fixed-top-centered placement as ApprovalBanner - canvas/src/components/Canvas.tsx: import and render - canvas/src/store/__tests__/canvas-events.test.ts: add BROADCAST_MESSAGE test suite (6 cases: correct fields, liveAnnouncement, sender fallback, empty guard, accumulation, XSS-like content) - canvas/src/components/__tests__/Canvas.{pan-to-node,a11y}.test.tsx: add broadcastMessages[] + consumeBroadcastMessages to mock store state; add BroadcastBanner stub - workspace/tests/test_a2a_tools_messaging.py: add TestToolBroadcastMessage (7 cases: empty/403/500/exception/200 with delivered count) and TestUploadChatFiles (7 cases: missing file, OSError, httpx exception, non-200, non-JSON, missing files key, file-count mismatch) Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/BroadcastBanner.tsx | 97 ++++++++ canvas/src/components/Canvas.tsx | 2 + .../components/__tests__/Canvas.a11y.test.tsx | 3 + .../__tests__/Canvas.pan-to-node.test.tsx | 3 + .../src/store/__tests__/canvas-events.test.ts | 151 +++++++++++- canvas/src/store/canvas-events.ts | 29 +++ canvas/src/store/canvas.ts | 12 + workspace/tests/test_a2a_tools_messaging.py | 225 ++++++++++++++++++ 8 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 canvas/src/components/BroadcastBanner.tsx 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 -- 2.52.0