Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """Tests for the active-path context-management middleware (langgraph-context plan). | |
| Red/green TDD oracle for ``src/loosecanvas/context_middleware.py``. The middleware | |
| implements the staged context policy from ``plan/langgraph-context``: | |
| * Phase A β instrumentation: measure context growth per model call. | |
| * Phase B β ``before_model`` compaction: keep recent dialogue verbatim, collapse | |
| stale tool chatter into compact structured receipts, drop superseded | |
| ``get_canvas_state`` payloads, enforce an optional token budget. | |
| The load-bearing correctness invariant is tested explicitly: compaction never | |
| splits an assistant ``tool_call`` from its matching ``ToolMessage`` (a model API | |
| rejects a dangling pair). | |
| """ | |
| from __future__ import annotations | |
| from typing import Any | |
| from langchain_core.messages import ( | |
| AIMessage, | |
| BaseMessage, | |
| HumanMessage, | |
| RemoveMessage, | |
| SystemMessage, | |
| ToolMessage, | |
| ) | |
| from langgraph.graph.message import REMOVE_ALL_MESSAGES | |
| from loosecanvas.context_middleware import ( | |
| SOURCE_PREAMBLE_MARKER, | |
| ContextCompactionMiddleware, | |
| ContextInstrumentationMiddleware, | |
| ContextStats, | |
| approx_tokens, | |
| compact_messages, | |
| measure_context, | |
| message_text, | |
| summarize_tool_call, | |
| ) | |
| # ββ Message builders ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _human(text: str, mid: str) -> HumanMessage: | |
| return HumanMessage(content=text, id=mid) | |
| def _ai( | |
| text: str = "", | |
| tool_calls: list[dict[str, Any]] | None = None, | |
| *, | |
| mid: str, | |
| ) -> AIMessage: | |
| return AIMessage(content=text, tool_calls=tool_calls or [], id=mid) | |
| def _tool(content: str, tool_call_id: str, name: str, mid: str) -> ToolMessage: | |
| return ToolMessage(content=content, tool_call_id=tool_call_id, name=name, id=mid) | |
| def _call(name: str, args: dict[str, Any], call_id: str) -> dict[str, Any]: | |
| return {"name": name, "args": args, "id": call_id, "type": "tool_call"} | |
| def _preamble(mid: str = "src") -> HumanMessage: | |
| text = f"{SOURCE_PREAMBLE_MARKER} below. Treat it as background.\n--- BEGIN ---\nlots of source text\n--- END ---" | |
| return HumanMessage(content=text, id=mid) | |
| def _completed_turn( | |
| n: int, | |
| *, | |
| ask: str = "add a node", | |
| tool_name: str = "create_node", | |
| tool_args: dict[str, Any] | None = None, | |
| tool_result: str = "ok", | |
| final: str = "done", | |
| ) -> list[BaseMessage]: | |
| """A full user turn: human ask β AI tool call β tool result β AI prose.""" | |
| args = ( | |
| tool_args if tool_args is not None else {"node_id": f"n{n}", "label": f"N{n}"} | |
| ) | |
| cid = f"call-{n}" | |
| return [ | |
| _human(f"{ask} {n}", f"h{n}"), | |
| _ai("", [_call(tool_name, args, cid)], mid=f"a{n}"), | |
| _tool(tool_result, cid, tool_name, f"t{n}"), | |
| _ai(final, mid=f"a{n}b"), | |
| ] | |
| def _tool_call_ids(messages: list[BaseMessage]) -> set[str]: | |
| ids: set[str] = set() | |
| for m in messages: | |
| if isinstance(m, AIMessage): | |
| for c in m.tool_calls: | |
| ids.add(str(c.get("id"))) | |
| return ids | |
| def _tool_result_ids(messages: list[BaseMessage]) -> set[str]: | |
| return {m.tool_call_id for m in messages if isinstance(m, ToolMessage)} | |
| # ββ approx_tokens / summarize_tool_call βββββββββββββββββββββββββββββββββββββββ | |
| def test_approx_tokens_empty_is_zero_and_scales() -> None: | |
| assert approx_tokens("") == 0 | |
| assert approx_tokens("a" * 4) >= 1 | |
| assert approx_tokens("a" * 400) > approx_tokens("a" * 40) | |
| def test_summarize_tool_call_uses_activity_label_convention() -> None: | |
| assert ( | |
| summarize_tool_call( | |
| "create_edge", {"source_id": "a", "target_id": "b", "label": "x"} | |
| ) | |
| == "create_edge:a->b" | |
| ) | |
| assert ( | |
| summarize_tool_call("create_node", {"node_id": "n1", "label": "N1"}) | |
| == "create_node:n1" | |
| ) | |
| assert summarize_tool_call("reveal_node", {"node_id": "x"}) == "reveal_node:x" | |
| assert summarize_tool_call("get_canvas_state", {}) == "get_canvas_state" | |
| assert summarize_tool_call("fog_all_except", {"node_ids": ["a", "b"]}).startswith( | |
| "fog_all_except" | |
| ) | |
| # ββ measure_context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_measure_context_classifies_messages_and_detects_source_seed() -> None: | |
| msgs: list[BaseMessage] = [ | |
| _preamble(), | |
| _human("hi", "h1"), | |
| _ai("", [_call("get_canvas_state", {}, "c1")], mid="a1"), | |
| _tool('{"nodes": []}', "c1", "get_canvas_state", "t1"), | |
| _ai("here you go", mid="a1b"), | |
| ] | |
| stats = measure_context(msgs) | |
| assert isinstance(stats, ContextStats) | |
| assert stats.total_messages == 5 | |
| assert stats.human_messages == 2 | |
| assert stats.ai_messages == 2 | |
| assert stats.tool_messages == 1 | |
| assert stats.ai_tool_call_messages == 1 | |
| assert stats.source_seed_tokens > 0 | |
| assert stats.approx_tokens >= stats.source_seed_tokens | |
| def test_measure_context_no_source_seed_is_zero() -> None: | |
| stats = measure_context([_human("hi", "h1"), _ai("hey", mid="a1")]) | |
| assert stats.source_seed_tokens == 0 | |
| assert stats.total_messages == 2 | |
| # ββ compact_messages β keep recent verbatim βββββββββββββββββββββββββββββββββββ | |
| def test_compact_keeps_recent_turns_verbatim() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| for n in (1, 2, 3): | |
| msgs += _completed_turn(n) | |
| out = compact_messages(msgs, keep_recent_turns=2) | |
| out_ids = [m.id for m in out] | |
| # Turns 2 and 3 (recent) survive verbatim β their tool messages remain. | |
| assert "t2" in out_ids and "t3" in out_ids # recent tool results kept | |
| assert "t1" not in out_ids # old tool result collapsed away | |
| assert "h1" in out_ids # old human ask kept verbatim | |
| def test_compact_collapses_old_tool_chatter_into_receipt() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| msgs += _completed_turn( | |
| 1, | |
| tool_name="create_node", | |
| tool_args={"node_id": "x", "label": "X"}, | |
| final="added X", | |
| ) | |
| msgs += _completed_turn(2) # recent, kept verbatim | |
| out = compact_messages(msgs, keep_recent_turns=1) | |
| # The old turn keeps its human ask, and folds prose + a receipt into one AIMessage | |
| # that has NO tool_calls; its ToolMessage is gone. | |
| assert "t1" not in [m.id for m in out] | |
| ai_after_h1 = next( | |
| m | |
| for i, m in enumerate(out) | |
| if isinstance(m, AIMessage) and out[i - 1].id == "h1" | |
| ) | |
| assert ai_after_h1.tool_calls == [] | |
| text = str(ai_after_h1.content) | |
| assert "added X" in text | |
| assert "create_node:x" in text | |
| def test_compact_never_leaves_dangling_tool_pairs() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| for n in (1, 2, 3, 4): | |
| msgs += _completed_turn(n) | |
| out = compact_messages(msgs, keep_recent_turns=2) | |
| # Every surviving tool_call has a matching tool result and vice-versa. | |
| assert _tool_call_ids(out) == _tool_result_ids(out) | |
| def test_compact_preserves_source_preamble_even_under_tiny_budget() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| for n in (1, 2, 3, 4, 5): | |
| msgs += _completed_turn(n) | |
| out = compact_messages(msgs, keep_recent_turns=1, token_budget=5) | |
| assert out[0].id == "src" | |
| assert SOURCE_PREAMBLE_MARKER in str(out[0].content) | |
| def test_compact_drops_superseded_canvas_state_payload() -> None: | |
| big_payload = '{"visible_node_ids": ' + str(list(range(200))) + "}" | |
| msgs: list[BaseMessage] = [ | |
| _preamble(), | |
| _human("show me", "h1"), | |
| _ai("", [_call("get_canvas_state", {}, "c1")], mid="a1"), | |
| _tool(big_payload, "c1", "get_canvas_state", "t1"), | |
| _ai("here", mid="a1b"), | |
| ] | |
| msgs += _completed_turn(2) # recent verbatim turn | |
| out = compact_messages(msgs, keep_recent_turns=1) | |
| blob = "".join(str(m.content) for m in out) | |
| assert big_payload not in blob # the stale payload is gone | |
| assert "get_canvas_state" in blob # but the receipt name survives | |
| def test_compact_token_budget_drops_oldest_turns_first() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| for n in (1, 2, 3, 4, 5): | |
| msgs += _completed_turn(n) | |
| out = compact_messages(msgs, keep_recent_turns=1, token_budget=200) | |
| ids = [m.id for m in out] | |
| # Recent turn (5) and anchor always survive. | |
| assert "src" in ids | |
| assert "h5" in ids and "t5" in ids | |
| # If anything old was dropped, the oldest (turn 1) goes before a newer one (turn 4). | |
| if "h1" not in ids: | |
| assert ids.index("src") == 0 | |
| # Either we fit the budget, or we're already down to anchor + recent turns. | |
| assert measure_context(out).approx_tokens <= 200 or "h2" not in ids | |
| def test_compact_is_noop_when_only_recent_turns() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| msgs += _completed_turn(1) | |
| out = compact_messages(msgs, keep_recent_turns=4) | |
| assert [m.id for m in out] == [m.id for m in msgs] | |
| # ββ Middleware classes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_instrumentation_middleware_measures_without_mutating() -> None: | |
| captured: list[ContextStats] = [] | |
| mw = ContextInstrumentationMiddleware(on_measure=captured.append) | |
| msgs: list[BaseMessage] = [_preamble(), _human("hi", "h1"), _ai("hey", mid="a1")] | |
| result = mw.before_model({"messages": msgs}, None) | |
| assert result is None # instrumentation never changes state | |
| assert len(captured) == 1 | |
| assert captured[0].total_messages == 3 | |
| def test_compaction_middleware_emits_remove_all_then_rebuilt() -> None: | |
| msgs: list[BaseMessage] = [_preamble()] | |
| for n in (1, 2, 3): | |
| msgs += _completed_turn(n) | |
| mw = ContextCompactionMiddleware(keep_recent_turns=1) | |
| result = mw.before_model({"messages": msgs}, None) | |
| assert result is not None | |
| new_msgs = result["messages"] | |
| assert isinstance(new_msgs[0], RemoveMessage) | |
| assert new_msgs[0].id == REMOVE_ALL_MESSAGES | |
| # The rest are the rebuilt history (no RemoveMessage among them). | |
| assert all(not isinstance(m, RemoveMessage) for m in new_msgs[1:]) | |
| def test_compaction_middleware_noop_returns_none() -> None: | |
| msgs: list[BaseMessage] = [_preamble(), *_completed_turn(1)] | |
| mw = ContextCompactionMiddleware(keep_recent_turns=4) | |
| assert mw.before_model({"messages": msgs}, None) is None | |
| def test_compaction_middleware_handles_leading_system_message() -> None: | |
| msgs: list[BaseMessage] = [SystemMessage(content="sys", id="s0"), _preamble()] | |
| for n in (1, 2, 3): | |
| msgs += _completed_turn(n) | |
| mw = ContextCompactionMiddleware(keep_recent_turns=1) | |
| result = mw.before_model({"messages": msgs}, None) | |
| assert result is not None | |
| rebuilt = result["messages"][1:] # skip RemoveMessage | |
| assert isinstance(rebuilt[0], SystemMessage) | |
| assert rebuilt[0].id == "s0" | |
| # ββ Drift guard: marker must match the harness preamble βββββββββββββββββββββββ | |
| def test_source_marker_matches_agent_harness_preamble() -> None: | |
| from loosecanvas.agent_harness import _format_source_preamble | |
| preamble = _format_source_preamble("Title", "body text") | |
| assert preamble.startswith(SOURCE_PREAMBLE_MARKER) | |
| # ββ Harness wiring: build_context_middleware ββββββββββββββββββββββββββββββββββ | |
| def test_build_context_middleware_default(monkeypatch: Any) -> None: | |
| from loosecanvas import agent_harness | |
| for var in ( | |
| "LOOSECANVAS_CONTEXT_COMPACTION", | |
| "LOOSECANVAS_CONTEXT_KEEP_TURNS", | |
| "LOOSECANVAS_CONTEXT_TOKEN_BUDGET", | |
| ): | |
| monkeypatch.delenv(var, raising=False) | |
| mw = agent_harness.build_context_middleware() | |
| assert isinstance(mw[0], ContextInstrumentationMiddleware) | |
| assert any(isinstance(m, ContextCompactionMiddleware) for m in mw) | |
| compaction = next(m for m in mw if isinstance(m, ContextCompactionMiddleware)) | |
| assert compaction._keep_recent_turns == 4 | |
| # W0b: unset budget now defaults to a generous runaway backstop (not unbounded). | |
| assert compaction._token_budget == agent_harness.DEFAULT_CONTEXT_TOKEN_BUDGET | |
| def test_build_context_middleware_compaction_disabled(monkeypatch: Any) -> None: | |
| from loosecanvas import agent_harness | |
| monkeypatch.setenv("LOOSECANVAS_CONTEXT_COMPACTION", "0") | |
| mw = agent_harness.build_context_middleware() | |
| assert len(mw) == 1 | |
| assert isinstance(mw[0], ContextInstrumentationMiddleware) | |
| def test_build_context_middleware_env_overrides(monkeypatch: Any) -> None: | |
| from loosecanvas import agent_harness | |
| monkeypatch.setenv("LOOSECANVAS_CONTEXT_COMPACTION", "1") | |
| monkeypatch.setenv("LOOSECANVAS_CONTEXT_KEEP_TURNS", "2") | |
| monkeypatch.setenv("LOOSECANVAS_CONTEXT_TOKEN_BUDGET", "12000") | |
| mw = agent_harness.build_context_middleware() | |
| compaction = next(m for m in mw if isinstance(m, ContextCompactionMiddleware)) | |
| assert compaction._keep_recent_turns == 2 | |
| assert compaction._token_budget == 12000 | |
| def test_build_context_middleware_budget_optout(monkeypatch: Any) -> None: | |
| """W0b: an explicit '0' opts out of the default budget (unbounded collapse-only).""" | |
| from loosecanvas import agent_harness | |
| monkeypatch.delenv("LOOSECANVAS_CONTEXT_COMPACTION", raising=False) | |
| monkeypatch.setenv("LOOSECANVAS_CONTEXT_TOKEN_BUDGET", "0") | |
| mw = agent_harness.build_context_middleware() | |
| compaction = next(m for m in mw if isinstance(m, ContextCompactionMiddleware)) | |
| assert compaction._token_budget is None | |
| def test_default_budget_bounds_a_long_history(monkeypatch: Any) -> None: | |
| """W0b: with the default budget (env unset), a very long session compacts to a | |
| bounded working set β old receipts drop, the anchor + recent turns survive.""" | |
| from loosecanvas import agent_harness | |
| for var in ( | |
| "LOOSECANVAS_CONTEXT_COMPACTION", | |
| "LOOSECANVAS_CONTEXT_KEEP_TURNS", | |
| "LOOSECANVAS_CONTEXT_TOKEN_BUDGET", | |
| ): | |
| monkeypatch.delenv(var, raising=False) | |
| mw = agent_harness.build_context_middleware() | |
| compaction = next(m for m in mw if isinstance(m, ContextCompactionMiddleware)) | |
| # A long session: a system anchor + 200 turns of (human ask, AI reply). Each | |
| # reply is long enough that the raw history far exceeds the default budget. | |
| long_reply = "detail " * 80 # ~140 tokens | |
| msgs: list[BaseMessage] = [SystemMessage(content="sys", id="sys")] | |
| for i in range(200): | |
| msgs.append(_human(f"question number {i} about the graph", mid=f"h{i}")) | |
| msgs.append(_ai(f"{long_reply} (reply {i})", mid=f"a{i}")) | |
| raw_tokens = sum(approx_tokens(message_text(m)) for m in msgs) | |
| assert raw_tokens > agent_harness.DEFAULT_CONTEXT_TOKEN_BUDGET # precondition | |
| out = compact_messages( | |
| msgs, | |
| keep_recent_turns=compaction._keep_recent_turns, | |
| token_budget=compaction._token_budget, | |
| ) | |
| out_tokens = sum(approx_tokens(message_text(m)) for m in out) | |
| assert out_tokens <= agent_harness.DEFAULT_CONTEXT_TOKEN_BUDGET + 500 | |
| assert len(out) < len(msgs) | |
| # The most recent turn survives verbatim. | |
| assert any(message_text(m) == "question number 199 about the graph" for m in out) | |