headroom/tests/integrations/test_langgraph.py
Rod Boev dcb674b5e4
fix(compression): honor qualified CCR names across integrations (#2698)
## Description

Three compression consumers compare tool names against the bare literal
`headroom_retrieve`, so the qualified forms MCP clients actually send
(`mcp__Headroom__headroom_retrieve`, `mcp_Headroom_headroom_retrieve`)
slip past the guard and get recompressed. `SmartCrusher.apply` has the
bare comparison at both its OpenAI `role=tool` site and its Anthropic
`tool_result` block site; the LangGraph compressor and the Strands hook
have no tool-name check at all. Recompressing already-retrieved CCR
content mints a new `<<ccr:hash>>` marker the agent cannot redeem.

`headroom.config.is_tool_excluded` already owns alias resolution,
including the MCP wrapper forms. This routes all three consumers through
it instead of adding a second name matcher. Closes #2656.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `SmartCrusher.apply` routes both its `role=tool` and its Anthropic
`tool_result` guards through `is_tool_excluded`
- `_should_skip` in the LangGraph compressor takes the tool name and
skips excluded tools; tool-call names are indexed by id so a
`ToolMessage` without a copied `name` is still classifiable
- `_should_skip_compression` in the Strands hook takes the tool name and
skips excluded tools, recording `tool_excluded`
- regressions for the qualified and bare names across all three
consumers, the Anthropic block shape, the MCP wrapper entry point, and a
near-match name that must still compress
- a LangGraph regression for incomplete tool-call metadata that
continues to a later qualified call

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

`pytest tests/test_smart_crusher.py tests/integrations/test_langgraph.py
tests/integrations/test_strands
tests/test_transforms/test_smart_crusher_ccr_retrieve_exemption.py -q`

```text
tests\test_smart_crusher.py ............                                     [ 10%]
tests\integrations\test_langgraph.py .....                                   [ 15%]
tests\integrations\test_strands\test_ccr_exclusion.py .....                  [ 19%]
tests\integrations\test_strands\test_hooks.py sssssssss                      [ 27%]
tests\integrations\test_strands\test_hooks_unit.py ssssssssssssssssssssssssssssssssss [ 57%]
tests\integrations\test_strands\test_model.py ssssssssssssssss               [ 71%]
tests\integrations\test_strands\test_model_unit.py sssssssssssssssssssssssssss [ 95%]
tests\test_transforms\test_smart_crusher_ccr_retrieve_exemption.py .....      [100%]

28 passed, 86 skipped in the focused invariant suite
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.13, `headroom._core` built
- Exact command / steps: `uv run pytest tests/test_smart_crusher.py
tests/integrations/test_langgraph.py tests/integrations/test_strands
-q`, and the same suite run against the pre-change implementation with
the new tests in place
- Observed result: before the change, five regressions fail.
`SmartCrusher` returns non-byte-identical content for a
`mcp__Headroom__headroom_retrieve` result, the LangGraph compressor
replaces the message content, and the Strands hook returns
`"compressed"` in place of the tool output. After the change all three
preserve the content byte-for-byte, incomplete LangGraph tool-call
metadata is ignored while the later qualified call remains indexed, the
Strands hook records `tool_excluded` and never calls the crusher, and
`HeadroomMCPCompressor.compress` returns the payload unchanged.
`mcp__Headroom__headroom_retrieve_extra` still compresses in all three,
and the Kompress and ContentRouter suites are unchanged.
- Not tested: the optional Strands package, so the additions to
`tests/integrations/test_strands/test_hooks_unit.py` skip locally

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

One deliberate divergence from the issue: the suggested snippet passes
`DEFAULT_VERBATIM_EXCLUDE_TOOLS` to `is_tool_excluded`, but that
constant holds only `WebSearch`, `WebFetch`, `web_search`, `web_fetch`.
Applied literally it would drop `headroom_retrieve` from the comparison
entirely and delete the #1077 guard these two SmartCrusher sites exist
to enforce. This passes `(CCR_TOOL_NAME,)` so each guard keeps doing the
one thing it documents. If you'd rather these paths also honor the
verbatim-exclude set, the tuple can become `(CCR_TOOL_NAME,
*DEFAULT_VERBATIM_EXCLUDE_TOOLS)` — the CCR name has to stay in it
either way.

Adjacent work: PR #2654 covers `ContentRouter` only.
2026-08-03 20:17:39 -07:00

90 lines
2.8 KiB
Python

"""Regression tests for qualified CCR retrieval tool names in LangGraph."""
from __future__ import annotations
import json
import pytest
pytest.importorskip("headroom._core")
try:
from langchain_core.messages import AIMessage, ToolMessage
except ImportError:
pytest.skip("LangChain not installed", allow_module_level=True)
from headroom.integrations.langchain.langgraph import compress_tool_messages
def _large_output() -> str:
return json.dumps([{"id": i, "name": f"item_{i}", "value": "x" * 30} for i in range(200)])
def _messages(tool_name: str) -> list:
return [
AIMessage(content="", tool_calls=[{"id": "call_1", "name": tool_name, "args": {}}]),
ToolMessage(content=_large_output(), tool_call_id="call_1"),
]
@pytest.mark.parametrize(
"tool_name",
["mcp__Headroom__headroom_retrieve", "mcp_Headroom_headroom_retrieve"],
)
def test_qualified_ccr_retrieval_message_is_preserved(tool_name: str) -> None:
messages = _messages(tool_name)
original = messages[1].content
result = compress_tool_messages(messages)
assert result.messages[1].content == original
assert result.metrics[0].skip_reason == "tool_excluded"
def test_incomplete_tool_calls_do_not_hide_later_qualified_name() -> None:
messages = [
AIMessage(
content="",
tool_calls=[
{"id": None, "name": "incomplete", "args": {}},
{"id": "ignored", "name": "", "args": {}},
{
"id": "call_1",
"name": "mcp__Headroom__headroom_retrieve",
"args": {},
},
],
),
ToolMessage(content=_large_output(), tool_call_id="call_1"),
]
original = messages[1].content
result = compress_tool_messages(messages)
assert result.messages[1].content == original
assert result.metrics[0].skip_reason == "tool_excluded"
def test_near_match_ccr_tool_name_is_not_excluded() -> None:
messages = _messages("mcp__Headroom__headroom_retrieve_extra")
original = messages[1].content
result = compress_tool_messages(messages)
assert result.metrics[0].skip_reason != "tool_excluded"
assert result.messages[1].content != original
@pytest.mark.parametrize(
"tool_name",
["mcp__Headroom__headroom_retrieve", "mcp_Headroom_headroom_retrieve"],
)
def test_qualified_name_on_the_tool_message_is_enough(tool_name: str) -> None:
"""`ToolNode` populates `ToolMessage.name`, so the id index is only a fallback."""
messages = [ToolMessage(content=_large_output(), tool_call_id="call_1", name=tool_name)]
original = messages[0].content
result = compress_tool_messages(messages)
assert result.messages[0].content == original
assert result.metrics[0].skip_reason == "tool_excluded"