loosecanvas / tests /test_agent_tools.py
Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff
Raw
History Blame Contribute Delete
8.71 kB
"""Tests for agent_tools.py — per-turn ContextVar accumulation + tool bridge.
RED discipline: this file is written before agent_tools.py exists.
Imports will fail with ModuleNotFoundError until the implementation lands.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
import pytest
from loosecanvas.agent_tools import (
TOOLS,
AgentTurnContext,
_current_turn,
create_edge,
create_node,
finalize_agent_turn,
get_canvas_state,
get_turn_context,
remove_node,
reveal_node,
set_turn_context,
update_edge,
)
from loosecanvas.turn_logic import (
TurnResult,
load_fixture_session,
session_locks,
session_store,
)
_REPO_ROOT = Path(__file__).resolve().parents[1]
_SMALL_GRAPH = _REPO_ROOT / "fixtures" / "small_graph.json"
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _isolate_sessions() -> Iterator[None]:
session_store.clear()
session_locks.clear()
yield
session_store.clear()
session_locks.clear()
@pytest.fixture(autouse=True)
def _reset_agent_context() -> Iterator[None]:
_current_turn.set(None)
yield
_current_turn.set(None)
@pytest.fixture
async def live_session_id() -> str:
sid, _ = await load_fixture_session(fixture_path=_SMALL_GRAPH)
return sid
# ── Lifecycle tests ───────────────────────────────────────────────────────────
def test_set_get_turn_context_lifecycle(live_session_id: str) -> None:
ctx = set_turn_context(live_session_id)
assert isinstance(ctx, AgentTurnContext)
assert ctx.session_id == live_session_id
assert ctx.accumulated_actions == []
assert ctx.activity == []
assert get_turn_context() is ctx
def test_get_turn_context_raises_when_unset() -> None:
# _reset_agent_context ensures no context is active
with pytest.raises(RuntimeError, match="no active agent turn context"):
get_turn_context()
# ── Tool tests ────────────────────────────────────────────────────────────────
async def test_reveal_node_accept_path(live_session_id: str) -> None:
set_turn_context(live_session_id)
# "learning_rate" is fogged in small_graph initial scene
result = reveal_node.invoke({"node_id": "learning_rate"})
data = json.loads(result)
assert data["status"] == "accepted"
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 1
assert ctx.accumulated_actions[0].type.value == "reveal"
assert ctx.accumulated_actions[0].target_id == "learning_rate"
assert len(ctx.activity) == 1
async def test_get_canvas_state_includes_visible_edges(live_session_id: str) -> None:
set_turn_context(live_session_id)
state = json.loads(get_canvas_state.invoke({}))
assert "visible_edges" in state, "agent must be able to see edges to relabel them"
for edge in state["visible_edges"]:
assert set(edge) >= {"id", "source", "target", "label"}
assert edge["id"] in state["visible_edge_ids"]
async def test_update_edge_accumulates_for_visible_edge(live_session_id: str) -> None:
set_turn_context(live_session_id)
state = json.loads(get_canvas_state.invoke({}))
if not state["visible_edges"]:
pytest.skip("fixture has no visible edge to relabel")
edge_id = state["visible_edges"][0]["id"]
result = json.loads(
update_edge.invoke({"edge_id": edge_id, "label": "clearer link"})
)
assert result["status"] == "accepted"
ctx = get_turn_context()
assert any(
a.type.value == "update_edge"
and a.edge_id == edge_id
and a.label == "clearer link"
for a in ctx.accumulated_actions
)
async def test_reveal_node_reject_path(live_session_id: str) -> None:
set_turn_context(live_session_id)
# Completely nonexistent node id — validator gives id_not_found
result = reveal_node.invoke({"node_id": "nonexistent_xyz_node"})
data = json.loads(result)
assert data["status"] == "rejected"
assert "hint" in data
# Hint should list valid fogged ids so the model can self-correct
hint = data["hint"]
assert any(
nid in hint for nid in ("learning_rate", "overfitting", "regularization")
)
# Nothing accumulated
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 0
async def test_get_canvas_state_no_mutation(live_session_id: str) -> None:
set_turn_context(live_session_id)
result = get_canvas_state.invoke({})
data = json.loads(result)
assert "visible_nodes" in data
assert "fogged_count" in data
assert "visible_edge_ids" in data
visible_ids = {n["id"] for n in data["visible_nodes"]}
assert "gradient_descent" in visible_ids
assert data["fogged_count"] == 3 # learning_rate, overfitting, regularization
# READ-ONLY: no accumulation
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 0
def test_finalize_agent_turn_no_actions_returns_none(live_session_id: str) -> None:
set_turn_context(live_session_id)
# No tool calls — accumulated_actions empty → pure-speech turn
result = finalize_agent_turn(live_session_id, {}, "Nothing to do on canvas.")
assert result is None
async def test_finalize_agent_turn_with_reveal_returns_turn_result(
live_session_id: str,
) -> None:
set_turn_context(live_session_id)
reveal_result = reveal_node.invoke({"node_id": "learning_rate"})
assert json.loads(reveal_result)["status"] == "accepted"
turn_result = finalize_agent_turn(live_session_id, {}, "Revealed learning rate.")
assert turn_result is not None
assert isinstance(turn_result, TurnResult)
assert turn_result.renderer_patch is not None
assert turn_result.status in ("success", "partial_success")
async def test_create_edge_resolves_same_turn_created_node(
live_session_id: str,
) -> None:
"""A create_edge to a node create_node'd EARLIER THIS TURN must be accepted.
The per-tool gate validates against the turn-so-far (accumulated actions), not just
the persisted graph/scene — patches only apply at finalize. Without this, the model
is told its same-turn edge was rejected and stalls (the "latency of truth" bug).
"""
set_turn_context(live_session_id)
node_result = create_node.invoke(
{"node_id": "concept::new", "label": "New Concept"}
)
assert json.loads(node_result)["status"] == "accepted"
edge_result = create_edge.invoke(
{
"source_id": "concept::new",
"target_id": "gradient_descent",
"label": "relates to",
}
)
assert json.loads(edge_result)["status"] == "accepted"
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 2
assert [a.type.value for a in ctx.accumulated_actions] == [
"create_node",
"create_edge",
]
async def test_duplicate_create_node_rejected_same_turn(
live_session_id: str,
) -> None:
"""A repeated create_node for an id minted earlier this turn must be rejected.
Without turn-so-far validation the node isn't in the persisted graph yet, so a retry
is accepted twice — producing the duplicate canvas mutations / receipts seen live.
"""
set_turn_context(live_session_id)
first = create_node.invoke({"node_id": "concept::dup", "label": "Dup"})
assert json.loads(first)["status"] == "accepted"
second = create_node.invoke({"node_id": "concept::dup", "label": "Dup"})
data = json.loads(second)
assert data["status"] == "rejected"
assert "id_exists" in data["reason"]
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 1
async def test_remove_node_accept_path(live_session_id: str) -> None:
set_turn_context(live_session_id)
# "gradient_descent" is visible; 5 total visible — removing one leaves 4 (> 0)
result = remove_node.invoke({"node_id": "gradient_descent"})
data = json.loads(result)
assert data["status"] == "accepted"
ctx = get_turn_context()
assert len(ctx.accumulated_actions) == 1
assert ctx.accumulated_actions[0].type.value == "remove_node"
def test_tools_list_populated() -> None:
assert (
len(TOOLS) >= 14
) # at least one tool per SceneActionType plus get_canvas_state