Workspace runtime fired four classes of A2A request to the platform
without the X-Workspace-ID header that identifies the source
workspace: heartbeat self-messages, initial_prompt, idle-loop fires,
and peer-to-peer A2A from runtime tools. The platform's a2a_receive
logger keys source_id off that header — without it, every such row
was written with source_id=NULL, which the canvas's My Chat tab
filters as ?source=canvas (i.e. "user typed this") and rendered the
internal triggers as if the human user had sent them. The
"Delegation results are ready..." heartbeat trigger was visible to
end users in the chat history; delegate_task A2A calls between agents
were misclassified the same way.
Centralise the header construction in a new platform_auth helper
self_source_headers(workspace_id) that returns auth_headers() PLUS
{X-Workspace-ID: <id>}. Apply it to:
- heartbeat.py self-message (refactored from inline header dict)
- main.py initial_prompt POST
- main.py idle_prompt POST
- a2a_client.py send_a2a_message (peer A2A from runtime)
- builtin_tools/a2a_tools.py delegate_task (was missing ALL headers)
Tests:
- test_heartbeat.py asserts the X-Workspace-ID header is set on
the self-message POST.
- test_a2a_tools_module.py asserts the same on delegate_task POSTs;
FakeClient.post mocks updated to accept the headers kwarg.
Production effect lands the moment workspace containers are rebuilt
with this code; existing rows in activity_logs keep their NULL
source_id (legacy data). The canvas-side filter (#follow-up)
covers the historical-rows case until backfill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Tests for heartbeat.py — HeartbeatLoop tracking and HTTP calls."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from heartbeat import HeartbeatLoop
|
|
|
|
|
|
def test_init():
|
|
"""HeartbeatLoop stores platform_url, workspace_id, and zeroes counters."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-123")
|
|
assert hb.platform_url == "http://localhost:8080"
|
|
assert hb.workspace_id == "ws-123"
|
|
assert hb.error_count == 0
|
|
assert hb.request_count == 0
|
|
assert hb.active_tasks == 0
|
|
assert hb.sample_error == ""
|
|
assert hb._task is None
|
|
|
|
|
|
def test_record_success():
|
|
"""record_success increments request_count only."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
hb.record_success()
|
|
hb.record_success()
|
|
assert hb.request_count == 2
|
|
assert hb.error_count == 0
|
|
|
|
|
|
def test_record_error():
|
|
"""record_error increments both counts and stores sample error."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
hb.record_error("timeout")
|
|
assert hb.request_count == 1
|
|
assert hb.error_count == 1
|
|
assert hb.sample_error == "timeout"
|
|
|
|
|
|
def test_error_rate_zero_requests():
|
|
"""error_rate is 0.0 when no requests have been recorded."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
assert hb.error_rate == 0.0
|
|
|
|
|
|
def test_error_rate_calculation():
|
|
"""error_rate correctly computes error_count / request_count."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
hb.record_success()
|
|
hb.record_success()
|
|
hb.record_error("fail")
|
|
hb.record_success()
|
|
# 1 error / 4 requests = 0.25
|
|
assert hb.error_rate == 0.25
|
|
|
|
|
|
def test_error_rate_all_errors():
|
|
"""error_rate is 1.0 when all requests are errors."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
hb.record_error("e1")
|
|
hb.record_error("e2")
|
|
assert hb.error_rate == 1.0
|
|
|
|
|
|
def test_sample_error_updated():
|
|
"""sample_error always reflects the most recent error."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
hb.record_error("first")
|
|
hb.record_error("second")
|
|
assert hb.sample_error == "second"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_loop_posts():
|
|
"""The _loop sends a POST to /registry/heartbeat with the correct payload."""
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
hb.record_error("some error")
|
|
hb.active_tasks = 2
|
|
|
|
mock_response = MagicMock()
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
|
|
# Run the loop but cancel after one iteration
|
|
async def run_one_iteration():
|
|
task = asyncio.create_task(hb._loop())
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
await run_one_iteration()
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
assert call_args[0][0] == "http://platform:8080/registry/heartbeat"
|
|
payload = call_args[1]["json"]
|
|
assert payload["workspace_id"] == "ws-abc"
|
|
assert payload["error_rate"] == 1.0 # 1 error / 1 request
|
|
assert payload["sample_error"] == "some error"
|
|
assert payload["active_tasks"] == 2
|
|
assert "uptime_seconds" in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_task():
|
|
"""stop() cancels the running heartbeat task."""
|
|
hb = HeartbeatLoop("http://localhost:8080", "ws-1")
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
|
|
hb.start()
|
|
assert hb._task is not None
|
|
await asyncio.sleep(0.01)
|
|
await hb.stop()
|
|
assert hb._task.cancelled() or hb._task.done()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_heartbeat_loop_continues_after_exception(capsys):
|
|
"""When the POST raises an exception, the loop prints a message and continues."""
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-err")
|
|
|
|
call_count = 0
|
|
|
|
async def fake_post(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise Exception("connection refused")
|
|
# Second call succeeds — return a mock response
|
|
return MagicMock()
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = fake_post
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
|
|
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
|
# Allow two iterations then cancel
|
|
iteration = 0
|
|
|
|
async def controlled_sleep(delay):
|
|
nonlocal iteration
|
|
iteration += 1
|
|
if iteration >= 2:
|
|
raise asyncio.CancelledError()
|
|
|
|
mock_sleep.side_effect = controlled_sleep
|
|
|
|
task = asyncio.create_task(hb._loop())
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# The loop ran at least once and logged the failure (via logger, not print)
|
|
# The loop continued (call_count reached at least 1)
|
|
assert call_count >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delegation checking tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegations_writes_results_file(tmp_path):
|
|
"""When completed delegations are found, results are written to file."""
|
|
import json
|
|
results_file = tmp_path / "delegation_results.jsonl"
|
|
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
|
|
delegations = [
|
|
{"delegation_id": "d-1", "status": "completed", "target_id": "ws-t",
|
|
"source_id": "ws-abc", # must match workspace_id for Fix B source validation
|
|
"summary": "Done", "response_preview": "Result here", "error": ""},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
# GET /delegations returns completed delegation
|
|
get_resp = MagicMock()
|
|
get_resp.status_code = 200
|
|
get_resp.json = MagicMock(return_value=delegations)
|
|
mock_client.get = AsyncMock(return_value=get_resp)
|
|
# POST for self-message and notify — just succeed
|
|
post_resp = MagicMock()
|
|
post_resp.status_code = 200
|
|
mock_client.post = AsyncMock(return_value=post_resp)
|
|
|
|
with patch("heartbeat.DELEGATION_RESULTS_FILE", str(results_file)):
|
|
await hb._check_delegations(mock_client)
|
|
|
|
# Verify file was written
|
|
assert results_file.exists()
|
|
lines = results_file.read_text().strip().split("\n")
|
|
assert len(lines) == 1
|
|
data = json.loads(lines[0])
|
|
assert data["delegation_id"] == "d-1"
|
|
assert data["status"] == "completed"
|
|
assert data["response_preview"] == "Result here"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegations_deduplicates():
|
|
"""Same delegation_id is not processed twice."""
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
hb._seen_delegation_ids.add("d-1") # Already seen
|
|
|
|
delegations = [
|
|
{"delegation_id": "d-1", "status": "completed", "target_id": "ws-t",
|
|
"summary": "Done", "response_preview": "old"},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
get_resp = MagicMock()
|
|
get_resp.status_code = 200
|
|
get_resp.json = MagicMock(return_value=delegations)
|
|
mock_client.get = AsyncMock(return_value=get_resp)
|
|
mock_client.post = AsyncMock()
|
|
|
|
with patch("heartbeat.DELEGATION_RESULTS_FILE", "/tmp/test_dedup.jsonl"):
|
|
await hb._check_delegations(mock_client)
|
|
|
|
# No self-message should be sent (delegation already seen)
|
|
# Only the GET call, no POST
|
|
mock_client.post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegations_sends_self_message(tmp_path):
|
|
"""Self-message A2A is sent when new completed delegations found."""
|
|
results_file = tmp_path / "results.jsonl"
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
|
|
delegations = [
|
|
{"delegation_id": "d-new", "status": "completed", "target_id": "ws-t",
|
|
"source_id": "ws-abc", # must match workspace_id for Fix B source validation
|
|
"summary": "Task done", "response_preview": "All good", "error": ""},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
get_resp = MagicMock()
|
|
get_resp.status_code = 200
|
|
get_resp.json = MagicMock(return_value=delegations)
|
|
mock_client.get = AsyncMock(return_value=get_resp)
|
|
post_resp = MagicMock()
|
|
post_resp.status_code = 200
|
|
mock_client.post = AsyncMock(return_value=post_resp)
|
|
|
|
with patch("heartbeat.DELEGATION_RESULTS_FILE", str(results_file)):
|
|
await hb._check_delegations(mock_client)
|
|
|
|
# Should have sent self-message (A2A to own workspace) + notify
|
|
post_calls = mock_client.post.call_args_list
|
|
assert len(post_calls) >= 1
|
|
# First POST should be the self-message A2A
|
|
a2a_call = post_calls[0]
|
|
assert "/a2a" in str(a2a_call)
|
|
|
|
# Regression: the self-message MUST include X-Workspace-ID set to
|
|
# the workspace's own id, so the platform's a2a_receive logger
|
|
# records source_id = workspace_id (not NULL). Without this header
|
|
# the canvas's My Chat tab (which filters source_id IS NULL) would
|
|
# render the internal "Delegation results are ready..." trigger
|
|
# as a user-typed message. Bug observed 2026-04-25 on UX A/B Lab
|
|
# Design Director chat.
|
|
a2a_headers = a2a_call.kwargs.get("headers") or {}
|
|
assert a2a_headers.get("X-Workspace-ID") == "ws-abc", (
|
|
f"self-message must self-identify via X-Workspace-ID header, "
|
|
f"got headers={a2a_headers!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_delegations_cooldown():
|
|
"""Self-message respects cooldown — no second message within 5 min."""
|
|
import time
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
hb._last_self_message_time = time.time() # Just sent one
|
|
|
|
delegations = [
|
|
{"delegation_id": "d-cool", "status": "completed", "target_id": "ws-t",
|
|
"summary": "Done", "response_preview": "ok", "error": ""},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
get_resp = MagicMock()
|
|
get_resp.status_code = 200
|
|
get_resp.json = MagicMock(return_value=delegations)
|
|
mock_client.get = AsyncMock(return_value=get_resp)
|
|
mock_client.post = AsyncMock()
|
|
|
|
with patch("heartbeat.DELEGATION_RESULTS_FILE", "/tmp/test_cooldown.jsonl"):
|
|
await hb._check_delegations(mock_client)
|
|
|
|
# File should still be written (results stored)
|
|
# But self-message should NOT be sent (cooldown active)
|
|
# Only notify POST, no A2A self-message
|
|
for call in mock_client.post.call_args_list:
|
|
assert "/a2a" not in str(call[0][0]), "Self-message should be blocked by cooldown"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seen_ids_eviction():
|
|
"""Seen delegation IDs are evicted when over MAX limit."""
|
|
from heartbeat import MAX_SEEN_DELEGATION_IDS
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
|
|
# Fill beyond max
|
|
for i in range(MAX_SEEN_DELEGATION_IDS + 50):
|
|
hb._seen_delegation_ids.add(f"d-{i}")
|
|
|
|
assert len(hb._seen_delegation_ids) > MAX_SEEN_DELEGATION_IDS
|
|
|
|
# Trigger eviction via _check_delegations with empty results
|
|
mock_client = AsyncMock()
|
|
get_resp = MagicMock()
|
|
get_resp.status_code = 200
|
|
get_resp.json = MagicMock(return_value=[])
|
|
mock_client.get = AsyncMock(return_value=get_resp)
|
|
|
|
await hb._check_delegations(mock_client)
|
|
|
|
# Should have been trimmed
|
|
assert len(hb._seen_delegation_ids) <= MAX_SEEN_DELEGATION_IDS
|
|
|
|
|
|
def test_on_done_restarts_loop():
|
|
"""_on_done restarts the loop when task has an exception."""
|
|
hb = HeartbeatLoop("http://platform:8080", "ws-abc")
|
|
|
|
# Create a mock failed task
|
|
mock_task = MagicMock()
|
|
mock_task.cancelled.return_value = False
|
|
mock_task.exception.return_value = RuntimeError("boom")
|
|
|
|
with patch("asyncio.create_task") as mock_create:
|
|
mock_new_task = MagicMock()
|
|
mock_create.return_value = mock_new_task
|
|
hb._on_done(mock_task)
|
|
|
|
# Should have created a new task
|
|
mock_create.assert_called_once()
|
|
# New task should have done callback
|
|
mock_new_task.add_done_callback.assert_called_once()
|