Files
roboco/tests/unit/agent_sdk/test_intake_driver.py
T
6cf99a1b0a [beb8cae1] Type-gate tests/ under mypy — fix all errors and flip quality gate (#156) (#157)
* [420e5e68] Fix mypy errors in tests/unit/ and create tests/__init__.py (#154)

* [420e5e68] fix(tests): resolve all mypy errors in tests/unit/ and create tests/__init__.py

- Create tests/__init__.py as empty package marker
- Add Any import and fix list type annotation in test_flow_server_intent_public_mapping.py
- Move AsyncIterator to TYPE_CHECKING block and fix m.cls.__name__ attr error in test_app.py
- Add return type annotations to _stub_get_optimal, _source, and factory functions
- Implement abstract methods (index_type, prepare_metadata, build_source_uri) in _FakePlugin
- Add pyproject.toml per-file-ignore for ARG002 on test_optimal_grounding.py stub
- Remove 4 stale # type: ignore comments from test_rate_limit_tracker.py
- Fix method-assignment patterns in test_rate_limit_sweep.py via patch.object
- All 487 source files pass mypy with 0 errors; 2312 unit tests pass

* [420e5e68] fix(tests): move stdlib/third-party imports to TYPE_CHECKING blocks across tests/unit/

Resolves 6 remaining ruff TC002/TC003 errors from the quality gate:
- test_handlers.py: Iterator → TYPE_CHECKING
- test_quality_gate.py: pathlib → TYPE_CHECKING
- test_board_dispatch.py: AsyncIterator + httpx → TYPE_CHECKING
- test_streaming.py: Iterator → TYPE_CHECKING
- test_notification.py: AsyncIterator → TYPE_CHECKING

All files have from __future__ import annotations so annotations are strings
at runtime; no runtime NameError risk from moving to TYPE_CHECKING.

* [420e5e68] fix(tests): use forward-ref cast() and drop unused TYPE_CHECKING import in 4 test files

* [420e5e68] chore(Makefile): scope lint mypy target to roboco/ to match gate and quality targets

---------



* [b0c9d41b] Fix mypy errors in tests/integration/ tests/foundation/ tests/property/ and update Makefile quality gates (#155)

* [b0c9d41b] fix(tests): resolve all mypy errors in tests/integration/, tests/foundation/, tests/property/

- Add missing type annotations to inner functions (_override_db, _override_agent_id, _req, etc.)
- Use cast("UUID", ...) to fix SQLAlchemy UUID vs uuid.UUID arg-type mismatches
- Remove stale # type: ignore comments from test_full_lifecycle_real_db.py and test_task_service_lifecycle_misc.py
- Update Makefile quality/quality-fast targets to run mypy on roboco/ tests/
- No runtime logic changed — annotations and cast() only

* [b0c9d41b] fix(tests): apply ruff TC006 quoted-cast and AsyncGenerator[T] fixes to complete mypy gate

- Quote all cast() type arguments per ruff TC006 rule (cast("T", x))
- Change AsyncGenerator[T, None] to AsyncGenerator[T] (Python 3.12 form)
- Move runtime-only imports to TYPE_CHECKING blocks (Path, Table, Generator, etc.)
- No runtime logic changed — annotation-only changeset

* [b0c9d41b] fix(Makefile): align lint target mypy scope with gate target (roboco/ only)

The lint target used `uv run mypy .` (all files) while gate uses `uv run mypy
roboco/`. This inconsistency caused the pre-submit gate to fail on 161 pre-existing
tests/unit/ errors (being fixed by sibling task 420e5e68). The quality/quality-fast
targets already check `roboco/ tests/` — the lint target now matches gate scope.

---------



---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
2026-06-14 13:43:46 +02:00

268 lines
8.6 KiB
Python

"""Unit tests for the intake driver loop + event normalization.
SDK-free: the `claude-agent-sdk` message types are stood in by tiny fakes named
the same way `normalize` keys off (`StreamEvent`, `AssistantMessage`, ...), and
the driver loop runs against a fake session/source/sink. The real
`SdkIntakeSession` adapter needs the live `claude` binary and is excluded from
coverage.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
import pytest
from roboco.agent_sdk.intake_driver import (
IntakeDriver,
StreamChunk,
normalize,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
# ---------------------------------------------------------------------------
# Fakes mirroring the claude-agent-sdk message/block shapes
# ---------------------------------------------------------------------------
class StreamEvent:
def __init__(self, event: dict) -> None:
self.event = event
class AssistantMessage:
def __init__(self, content: list) -> None:
self.content = content
class ResultMessage:
def __init__(self, session_id: str, total_cost_usd: float | None = None) -> None:
self.session_id = session_id
self.total_cost_usd = total_cost_usd
class SystemMessage:
def __init__(self, subtype: str) -> None:
self.subtype = subtype
class TextBlock:
def __init__(self, text: str) -> None:
self.text = text
class ThinkingBlock:
def __init__(self, thinking: str) -> None:
self.thinking = thinking
class ToolUseBlock:
def __init__(self, name: str, tool_input: dict) -> None:
self.name = name
self.input = tool_input
# ---------------------------------------------------------------------------
# normalize()
# ---------------------------------------------------------------------------
def test_normalize_stream_event_text_delta() -> None:
msg = StreamEvent({"delta": {"type": "text_delta", "text": "hel"}})
chunks = normalize(msg)
assert chunks == [StreamChunk(kind="text", text="hel")]
def test_normalize_stream_event_non_text_delta_is_dropped() -> None:
assert normalize(StreamEvent({"delta": {"type": "input_json_delta"}})) == []
assert normalize(StreamEvent({})) == []
def test_normalize_assistant_message_blocks() -> None:
# Text is NOT re-emitted from the AssistantMessage (the StreamEvent deltas
# already carried it live) — only thinking + tool_use, which have no deltas.
msg = AssistantMessage(
[
TextBlock("hello"),
ThinkingBlock("hmm"),
ToolUseBlock("Read", {"file": "metrics.tsx"}),
]
)
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["thinking", "tool_use"]
assert chunks[0].text == "hmm"
assert chunks[1].tool == "Read"
assert chunks[1].data["input"] == {"file": "metrics.tsx"}
def test_normalize_assistant_message_extracts_draft_block() -> None:
# A finished reply that ends with a fenced roboco-draft block yields a
# single `draft` chunk carrying the parsed object — and no `text` chunk.
text = (
"Here is the task.\n"
"```roboco-draft\n"
'{"title": "Add metrics", "acceptance_criteria": ["x"], "scale": "single"}\n'
"```\n"
)
chunks = normalize(AssistantMessage([TextBlock(text)]))
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Add metrics"
assert chunks[0].data["scale"] == "single"
def test_normalize_assistant_message_malformed_draft_is_ignored() -> None:
bad = "```roboco-draft\n{not valid json}\n```"
assert normalize(AssistantMessage([TextBlock(bad)])) == []
# A draft block with no title is not a usable draft either.
no_title = '```roboco-draft\n{"acceptance_criteria": []}\n```'
assert normalize(AssistantMessage([TextBlock(no_title)])) == []
def test_normalize_propose_draft_tool_becomes_draft_chunk() -> None:
# The canonical signal: the agent CALLS propose_draft → one `draft` chunk
# (not a tool_use chunk).
msg = AssistantMessage(
[
ToolUseBlock(
"propose_draft",
{"draft": {"title": "Add metrics", "acceptance_criteria": ["x"]}},
)
]
)
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Add metrics"
def test_normalize_propose_draft_accepts_flat_input() -> None:
# Tolerant of the draft fields passed flat (no "draft" wrapper).
msg = AssistantMessage([ToolUseBlock("propose_draft", {"title": "Flat", "x": 1})])
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Flat"
def test_normalize_propose_draft_namespaced_name() -> None:
# However the SDK namespaces it (e.g. mcp__intake__propose_draft).
msg = AssistantMessage(
[ToolUseBlock("mcp__intake__propose_draft", {"draft": {"title": "NS"}})]
)
assert [c.kind for c in normalize(msg)] == ["draft"]
def test_normalize_other_tool_stays_tool_use() -> None:
chunks = normalize(AssistantMessage([ToolUseBlock("Read", {"file": "x.py"})]))
assert [c.kind for c in chunks] == ["tool_use"]
assert chunks[0].tool == "Read"
def test_normalize_propose_draft_without_title_is_ignored() -> None:
msg = AssistantMessage(
[ToolUseBlock("propose_draft", {"draft": {"acceptance_criteria": []}})]
)
assert normalize(msg) == []
def test_normalize_result_message_carries_session_id() -> None:
cost = 0.01
chunks = normalize(ResultMessage(session_id="sess-123", total_cost_usd=cost))
assert len(chunks) == 1
assert chunks[0].kind == "turn_end"
assert chunks[0].data["session_id"] == "sess-123"
assert chunks[0].data["cost_usd"] == cost
def test_normalize_system_message() -> None:
chunks = normalize(SystemMessage(subtype="init"))
assert chunks == [StreamChunk(kind="system", data={"subtype": "init"})]
def test_normalize_unknown_message_is_empty() -> None:
assert normalize(object()) == []
# ---------------------------------------------------------------------------
# IntakeDriver loop
# ---------------------------------------------------------------------------
class _FakeSession:
"""Scripts each input text to a list of chunks to stream back."""
def __init__(self, scripted: dict[str, list[StreamChunk]]) -> None:
self.scripted = scripted
self.seen: list[str] = []
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
self.seen.append(text)
for chunk in self.scripted.get(text, []):
yield chunk
class _RaisingSession:
"""Streams one chunk, then fails mid-turn (faithful to a live SDK error)."""
async def send(self, _text: str) -> AsyncIterator[StreamChunk]:
yield StreamChunk(kind="text", text="partial")
raise RuntimeError("boom")
def _source(messages: list[str | None]) -> Callable[[], Awaitable[str | None]]:
queue = list(messages)
async def _next() -> str | None:
return queue.pop(0) if queue else None
return _next
@pytest.mark.asyncio
async def test_driver_streams_turns_until_shutdown() -> None:
session = _FakeSession(
{
"hi": [StreamChunk(kind="text", text="hello there")],
"more": [
StreamChunk(kind="tool_use", tool="Read"),
StreamChunk(kind="text", text="done"),
],
}
)
@asynccontextmanager
async def factory() -> AsyncIterator[_FakeSession]:
yield session
collected: list[StreamChunk] = []
async def emit(chunk: StreamChunk) -> None:
collected.append(chunk)
driver = IntakeDriver(factory, _source(["hi", "more", None]), emit)
await driver.run()
assert session.seen == ["hi", "more"] # stopped on None, did not call send(None)
assert [c.kind for c in collected] == ["text", "tool_use", "text"]
assert collected[0].text == "hello there"
@pytest.mark.asyncio
async def test_driver_turn_failure_emits_error_and_continues() -> None:
@asynccontextmanager
async def factory() -> AsyncIterator[_RaisingSession]:
yield _RaisingSession()
collected: list[StreamChunk] = []
async def emit(chunk: StreamChunk) -> None:
collected.append(chunk)
driver = IntakeDriver(factory, _source(["boom-please", None]), emit)
await driver.run() # must not raise
# The partial chunk made it out, then the failure surfaced as an error chunk.
assert [c.kind for c in collected] == ["text", "error"]
assert collected[0].text == "partial"
assert "boom" in collected[1].text