Joshua Sundance Bailey
feat: open-world trust-loop UX (chat-first review, edge review, layout reshape)
a973dff | """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 ────────────────────────────────────────────────────────────────── | |
| def _isolate_sessions() -> Iterator[None]: | |
| session_store.clear() | |
| session_locks.clear() | |
| yield | |
| session_store.clear() | |
| session_locks.clear() | |
| def _reset_agent_context() -> Iterator[None]: | |
| _current_turn.set(None) | |
| yield | |
| _current_turn.set(None) | |
| 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 | |