Compare commits

...

1 Commits

Author SHA1 Message Date
fullstack-engineer d4713a0725 feat(canvas): wire BROADCAST_MESSAGE WebSocket event to a dismissible banner
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 25s
Harness Replays / detect-changes (pull_request) Successful in 26s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 1m26s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 31s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m45s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m37s
qa-review / approved (pull_request) Successful in 37s
security-review / approved (pull_request) Successful in 32s
Harness Replays / Harness Replays (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m21s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m28s
CI / Python Lint & Test (pull_request) Successful in 8m29s
CI / Canvas (Next.js) (pull_request) Successful in 19m48s
CI / Platform (Go) (pull_request) Failing after 20m48s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 33s
sop-tier-check / tier-check (pull_request) Successful in 34s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 1/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +3
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 <BroadcastBanner />
- 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 <noreply@anthropic.com>
2026-05-15 09:39:37 +00:00
8 changed files with 520 additions and 2 deletions
+97
View File
@@ -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 (
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
{broadcastMessages.map((msg) => (
<div
key={msg.id}
role="alert"
aria-live="polite"
aria-atomic="true"
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
>
<div className="flex items-start gap-3">
{/* Megaphone icon */}
<div
aria-hidden="true"
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-300"
>
<path d="M3 11l18-5v12L3 13v-2z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-blue-300 font-semibold">
Broadcast from{" "}
<span className="text-blue-100">{msg.sender}</span>
</div>
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
{msg.message}
</div>
</div>
{/* Dismiss button */}
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss broadcast"
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
))}
</div>
);
}
+2
View File
@@ -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() {
<OnboardingWizard />
<Toolbar />
<ApprovalBanner />
<BroadcastBanner />
<BundleDropZone />
<TemplatePalette />
<SidePanel />
@@ -73,6 +73,8 @@ const mockStoreState = {
clearSelection: vi.fn(),
toggleNodeSelection: vi.fn(),
deletingIds: new Set<string>(),
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", () => ({
@@ -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<string>(),
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", () => ({
@@ -53,9 +53,10 @@ function makeStore(
edges: Edge[] = [],
selectedNodeId: string | null = null,
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
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<string, unknown>) => {
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: "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
},
}),
get,
set
);
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
expect(next.broadcastMessages[0].message).toBe("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
});
});
+29
View File
@@ -72,6 +72,7 @@ export function handleCanvasEvent(
edges: Edge[];
selectedNodeId: string | null;
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
},
set: (partial: Record<string, unknown>) => 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;
}
+12
View File
@@ -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<CanvasState>((set, get) => ({
@@ -342,6 +348,12 @@ export const useCanvasStore = create<CanvasState>((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 },
+225
View File
@@ -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