mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `headroom proxy --intercept-tool-results` sets `HEADROOM_INTERCEPT_ENABLED=1` but the interceptor is never registered. The proxy server constructs its transform pipeline with an explicit list (`server.py:645-648`), bypassing `_build_default_transforms` (`pipeline.py:113-118`) where the env-var check lives. The flag is silently ignored. This PR mirrors the env-var check in `server.py` immediately after the explicit transforms list, inserting `ToolResultInterceptorTransform()` at index 0 when `HEADROOM_INTERCEPT_ENABLED` is set (any truthy value). This matches the truthiness-based activation in `_build_default_transforms` at `pipeline.py:113-114`. Closes #829 ## 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 - `headroom/proxy/server.py`: after the explicit transforms list (~line 692), check `os.environ.get("HEADROOM_INTERCEPT_ENABLED")` (truthy, matching `pipeline.py`) and prepend `ToolResultInterceptorTransform()` to both Anthropic and OpenAI pipelines - `tests/test_tool_result_interceptors.py`: two tests covering interceptor presence when env var is set and absence when unset - `CHANGELOG.md`: bug fix entry ## Testing - [x] Unit tests pass (`uv run pytest tests/test_tool_result_interceptors.py -v -k "proxy_pipeline"`) - [x] Linting passes (`uv run ruff check .`) - [ ] Type checking passes (`uv run mypy headroom`) — N/A: repo does not enforce mypy in CI - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text # paste actual pytest -v output here after running ``` ## Real Behavior Proof - Environment: headroom proxy with `HEADROOM_INTERCEPT_ENABLED=1` - Exact command / steps: construct `HeadroomProxy(ProxyConfig())` with env var set, inspect `anthropic_pipeline.transforms` - Observed result: `ToolResultInterceptorTransform` present at index 0 in the pipeline transforms list - Not tested: end-to-end interception of a live streaming response; interaction with Bedrock pipeline path ## 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 - [x] 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 have updated the CHANGELOG.md if applicable ## Additional Notes No `ProxyConfig` plumbing is needed because the CLI already sets the env var at `proxy.py:741,759`. The fix is ~8 LOC in server.py. The activation uses bare truthiness (`os.environ.get(...)`) to match `pipeline.py:113-114`, so any non-empty value enables the interceptor. PR #831 (luv-jeri) is stale and labeled "status: needs author action" since 2026-06-19; this is an independent clean fix.
730 lines
24 KiB
Python
730 lines
24 KiB
Python
"""Tests for the tool_result interceptor framework + ast-grep Read outliner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from headroom.proxy.interceptors import (
|
|
INTERCEPTORS,
|
|
ToolResultInterceptor,
|
|
ToolResultInterceptorTransform,
|
|
apply_to_messages,
|
|
interceptor_failure_counts,
|
|
register,
|
|
)
|
|
from headroom.proxy.interceptors.astgrep import AstGrepReadOutline
|
|
from headroom.proxy.interceptors.base import reset_interceptor_failure_counts
|
|
from headroom.tokenizer import Tokenizer
|
|
|
|
|
|
class _FakeTokenCounter:
|
|
"""Deterministic 4-chars-per-token counter for unit tests."""
|
|
|
|
def count_text(self, text: str) -> int:
|
|
return max(1, len(text) // 4)
|
|
|
|
def count_messages(self, messages) -> int:
|
|
total = 0
|
|
for m in messages:
|
|
c = m.get("content")
|
|
if isinstance(c, str):
|
|
total += self.count_text(c)
|
|
elif isinstance(c, list):
|
|
for b in c:
|
|
if isinstance(b, dict):
|
|
inner = b.get("content") or b.get("text") or ""
|
|
if isinstance(inner, str):
|
|
total += self.count_text(inner)
|
|
return total
|
|
|
|
|
|
@pytest.fixture
|
|
def tokenizer() -> Tokenizer:
|
|
# Real Tokenizer wrapping the fake counter; mirrors production construction.
|
|
return Tokenizer(_FakeTokenCounter()) # type: ignore[arg-type]
|
|
|
|
|
|
# -------- Framework basics ----------------------------------------------- #
|
|
|
|
|
|
def test_astgrep_interceptor_registered_by_default():
|
|
assert any(i.name == "ast-grep" for i in INTERCEPTORS)
|
|
|
|
|
|
def test_register_is_idempotent_on_name():
|
|
before = len(INTERCEPTORS)
|
|
register(AstGrepReadOutline()) # same name
|
|
assert len(INTERCEPTORS) == before
|
|
|
|
|
|
def test_custom_interceptor_plugs_in(tokenizer):
|
|
class UpperCase:
|
|
name = "uppercase-test"
|
|
|
|
def matches(self, tool_name, tool_input, tool_output):
|
|
return tool_name == "Echo"
|
|
|
|
def transform(self, tool_name, tool_input, tool_output):
|
|
# Must REDUCE tokens — use a single short marker.
|
|
return "X"
|
|
|
|
dummy: ToolResultInterceptor = UpperCase() # type: ignore[assignment]
|
|
register(dummy)
|
|
try:
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [{"type": "tool_use", "id": "1", "name": "Echo", "input": {}}],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "1",
|
|
"content": "hello " * 100,
|
|
}
|
|
],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert any(s.tool == "uppercase-test" for s in result.spans)
|
|
swapped = result.messages[1]["content"][0]["content"]
|
|
assert swapped == "X"
|
|
finally:
|
|
INTERCEPTORS[:] = [i for i in INTERCEPTORS if i.name != "uppercase-test"]
|
|
|
|
|
|
def test_pass_through_when_no_interceptor_matches(tokenizer):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [{"type": "tool_use", "id": "1", "name": "Unknown", "input": {}}],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "1", "content": "x" * 5000}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert result.spans == []
|
|
assert result.messages[1] is messages[1] # untouched identity
|
|
|
|
|
|
# -------- ast-grep interceptor ------------------------------------------- #
|
|
|
|
|
|
_PY_FIXTURE = textwrap.dedent(
|
|
'''
|
|
"""Payments module fixture."""
|
|
from decimal import Decimal
|
|
|
|
def compute_subtotal(items):
|
|
total = Decimal("0")
|
|
for item in items:
|
|
total += item.price * item.qty
|
|
return total
|
|
|
|
|
|
def apply_promo(subtotal, code):
|
|
if not code:
|
|
return subtotal
|
|
if code == "SAVE10":
|
|
return subtotal * Decimal("0.9")
|
|
return subtotal
|
|
|
|
|
|
def compute_tax(subtotal, rate):
|
|
return (subtotal * rate).quantize(Decimal("0.01"))
|
|
|
|
|
|
def process_payment(items, promo, tax_rate):
|
|
"""Main entry point."""
|
|
subtotal = compute_subtotal(items)
|
|
after = apply_promo(subtotal, promo)
|
|
tax = compute_tax(after, tax_rate)
|
|
return after + tax
|
|
|
|
|
|
def refund(order_id, amount):
|
|
"""Issue a refund."""
|
|
return {"order": order_id, "refund": str(amount)}
|
|
|
|
|
|
def list_orders_for_user(user_id, limit=20):
|
|
"""Placeholder DB lookup for a user's orders."""
|
|
return [{"user": user_id, "order": i} for i in range(limit)]
|
|
|
|
|
|
def cancel_order(order_id, reason=None):
|
|
"""Cancel an order, logging the reason if provided."""
|
|
return {"order": order_id, "cancelled": True, "reason": reason or "unspecified"}
|
|
|
|
|
|
def summarize_cart(items):
|
|
"""Return a one-line summary of cart contents."""
|
|
skus = [i.sku for i in items]
|
|
total_qty = sum(i.qty for i in items)
|
|
return f"{len(items)} line items ({total_qty} units): {', '.join(skus)}"
|
|
|
|
|
|
def format_receipt(order_id, items, total):
|
|
"""Render a textual receipt."""
|
|
lines = [f"Order {order_id}"]
|
|
for i in items:
|
|
lines.append(f" {i.sku} x {i.qty} @ {i.unit_price} = {i.qty * i.unit_price}")
|
|
lines.append(f"Total: {total}")
|
|
return "\\n".join(lines)
|
|
'''
|
|
).strip()
|
|
|
|
|
|
def test_astgrep_outlines_large_python_read(tokenizer):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "abc",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "abc", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert len(result.spans) == 1
|
|
span = result.spans[0]
|
|
assert span.tool == "ast-grep"
|
|
assert span.tokens_after < span.tokens_before
|
|
new_content = result.messages[1]["content"][0]["content"]
|
|
assert "outlined by ast-grep" in new_content
|
|
assert "body elided" in new_content
|
|
assert "def process_payment" in new_content
|
|
assert "def apply_promo" in new_content
|
|
# Bodies should NOT leak through unchanged.
|
|
assert "total += item.price * item.qty" not in new_content
|
|
|
|
|
|
def test_astgrep_skips_small_files(tokenizer):
|
|
small = "def foo(): return 1\n"
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "x",
|
|
"name": "Read",
|
|
"input": {"file_path": "/a.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "x", "content": small}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert result.spans == []
|
|
|
|
|
|
def test_astgrep_skips_non_code_extensions(tokenizer):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "r",
|
|
"name": "Read",
|
|
"input": {"file_path": "/notes.txt"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "r", "content": "x" * 3000}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert result.spans == []
|
|
|
|
|
|
# -------- OpenAI-format tool_result -------------------------------------- #
|
|
|
|
|
|
def test_astgrep_skips_when_line_range_requested(tokenizer):
|
|
"""If the tool_input specifies a line range, the model wants those lines — pass through."""
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "r",
|
|
"name": "Read",
|
|
"input": {
|
|
"file_path": "/repo/payments.py",
|
|
"offset": 30,
|
|
"limit": 20,
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "r", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert result.spans == []
|
|
|
|
|
|
def test_progressive_disclosure_second_read_passes_through(tokenizer):
|
|
"""First Read of a file gets outlined; second Read of the same path is untouched."""
|
|
messages = [
|
|
# Turn 1: Read foo.py → outlined
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t1",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": _PY_FIXTURE}],
|
|
},
|
|
# Turn 2: Read foo.py again (model came back for more) → pass through
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t2",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
# Only the first Read is rewritten; the second keeps its full body.
|
|
assert len(result.spans) == 1
|
|
first_tr = result.messages[1]["content"][0]["content"]
|
|
second_tr = result.messages[3]["content"][0]["content"]
|
|
assert "outlined by ast-grep" in first_tr
|
|
assert "outlined by ast-grep" not in second_tr
|
|
assert "def process_payment" in second_tr
|
|
# Second Read preserves the bodies.
|
|
assert "subtotal = compute_subtotal(items)" in second_tr
|
|
|
|
|
|
def test_progressive_disclosure_different_file_still_outlined(tokenizer):
|
|
"""Reading a DIFFERENT file after the first outline should still outline."""
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t1",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": _PY_FIXTURE}],
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t2",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/other.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
# Both files get outlined — different keys.
|
|
assert len(result.spans) == 2
|
|
|
|
|
|
def test_openai_format_tool_result_is_rewritten(tokenizer):
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "Read",
|
|
"arguments": '{"file_path": "/x/payments.py"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_1",
|
|
"content": _PY_FIXTURE,
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert len(result.spans) == 1
|
|
new_content = result.messages[1]["content"]
|
|
assert "outlined by ast-grep" in new_content
|
|
|
|
|
|
# -------- Failure isolation & safety guarantees -------------------------- #
|
|
|
|
|
|
def test_failing_interceptor_does_not_crash_request(tokenizer):
|
|
"""If transform() raises, the request still succeeds unchanged."""
|
|
reset_interceptor_failure_counts()
|
|
|
|
class BoomInterceptor:
|
|
name = "boom"
|
|
|
|
def matches(self, tool_name, tool_input, tool_output):
|
|
return tool_name == "Read"
|
|
|
|
def transform(self, tool_name, tool_input, tool_output):
|
|
raise RuntimeError("simulated interceptor bug")
|
|
|
|
register(BoomInterceptor())
|
|
try:
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "b",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "b", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
# No span recorded for boom; request survives.
|
|
assert not any(s.tool == "boom" for s in result.spans)
|
|
# The failure counter incremented.
|
|
assert interceptor_failure_counts().get("boom") == 1
|
|
finally:
|
|
INTERCEPTORS[:] = [i for i in INTERCEPTORS if i.name != "boom"]
|
|
|
|
|
|
def test_failing_key_skips_interceptor_entirely(tokenizer):
|
|
"""Broken progressive_disclosure_key() must skip, not fire without a key."""
|
|
reset_interceptor_failure_counts()
|
|
fire_count = {"n": 0}
|
|
|
|
class BadKey:
|
|
name = "bad-key"
|
|
|
|
def matches(self, tool_name, tool_input, tool_output):
|
|
return tool_name == "Read"
|
|
|
|
def transform(self, tool_name, tool_input, tool_output):
|
|
fire_count["n"] += 1
|
|
return "X" # reduces tokens
|
|
|
|
def progressive_disclosure_key(self, tool_name, tool_input):
|
|
raise RuntimeError("cannot compute key")
|
|
|
|
register(BadKey())
|
|
try:
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "k",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "k", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
apply_to_messages(messages, tokenizer)
|
|
assert fire_count["n"] == 0 # transform never ran
|
|
assert interceptor_failure_counts().get("bad-key") == 1
|
|
finally:
|
|
INTERCEPTORS[:] = [i for i in INTERCEPTORS if i.name != "bad-key"]
|
|
|
|
|
|
def test_refuses_to_enlarge(tokenizer):
|
|
"""If rewrite has MORE tokens than original, pass through unchanged.
|
|
|
|
Uses a non-code tool path so only the Inflater runs (ast-grep passes
|
|
through on non-Read tools).
|
|
"""
|
|
original_content = "some data " * 200
|
|
|
|
class Inflater:
|
|
name = "inflater"
|
|
|
|
def matches(self, tool_name, tool_input, tool_output):
|
|
return tool_name == "FetchPage"
|
|
|
|
def transform(self, tool_name, tool_input, tool_output):
|
|
return tool_output + (" padding" * 200)
|
|
|
|
register(Inflater())
|
|
try:
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "i",
|
|
"name": "FetchPage",
|
|
"input": {"url": "https://example.com"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "tool_result", "tool_use_id": "i", "content": original_content}
|
|
],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
assert not any(s.tool == "inflater" for s in result.spans)
|
|
# Original content preserved.
|
|
assert result.messages[1]["content"][0]["content"] == original_content
|
|
finally:
|
|
INTERCEPTORS[:] = [i for i in INTERCEPTORS if i.name != "inflater"]
|
|
|
|
|
|
def test_orphaned_tool_result_does_not_crash(tokenizer):
|
|
"""A tool_result with no matching tool_use still runs safely (no tool_name)."""
|
|
messages = [
|
|
# No tool_use block — the model's prior turn is missing.
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "tool_result", "tool_use_id": "orphan-id", "content": _PY_FIXTURE}
|
|
],
|
|
},
|
|
]
|
|
result = apply_to_messages(messages, tokenizer)
|
|
# ast-grep.matches() returns False when tool_name is None, so no span.
|
|
assert result.spans == []
|
|
# The orphan message is preserved.
|
|
assert result.messages[0]["content"][0]["content"] == _PY_FIXTURE
|
|
|
|
|
|
# -------- Transform adapter tests ---------------------------------------- #
|
|
|
|
|
|
def test_transform_adapter_applies_interceptors(tokenizer):
|
|
"""ToolResultInterceptorTransform.apply() runs interceptors + records tokens."""
|
|
transform = ToolResultInterceptorTransform()
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "a",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "a", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = transform.apply(messages, tokenizer)
|
|
assert result.tokens_after < result.tokens_before
|
|
assert "interceptor:ast-grep" in result.transforms_applied
|
|
|
|
|
|
def test_transform_adapter_respects_frozen_message_count(tokenizer):
|
|
"""Messages in the frozen prefix must be untouched to preserve prefix caches."""
|
|
transform = ToolResultInterceptorTransform()
|
|
messages = [
|
|
# Frozen prefix (first tool_result) — MUST pass through unchanged.
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t1",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/a.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": _PY_FIXTURE}],
|
|
},
|
|
# Mutable tail (second Read of a different file) — free to outline.
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "t2",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/b.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": _PY_FIXTURE}],
|
|
},
|
|
]
|
|
result = transform.apply(messages, tokenizer, frozen_message_count=2)
|
|
# Frozen prefix identity preserved (exact same list refs).
|
|
assert result.messages[0] is messages[0]
|
|
assert result.messages[1] is messages[1]
|
|
# Tail got outlined.
|
|
assert "outlined by ast-grep" in result.messages[3]["content"][0]["content"]
|
|
|
|
|
|
def test_progressive_disclosure_respects_frozen_prefix_history(tokenizer):
|
|
"""If a file was Read in the frozen prefix, re-reading it in the mutable
|
|
tail passes through — even though apply_to_messages only sees the tail
|
|
for rewriting, it pre-scans the frozen prefix to seed `fired` keys.
|
|
"""
|
|
transform = ToolResultInterceptorTransform()
|
|
messages = [
|
|
# Frozen prefix: first Read of payments.py. This is cached, so we
|
|
# don't outline it; but it counts as "already disclosed."
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "frozen-read",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "frozen-read",
|
|
"content": _PY_FIXTURE,
|
|
}
|
|
],
|
|
},
|
|
# Mutable tail: model reads payments.py again — should pass through
|
|
# because the frozen prefix already served it.
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tail-read",
|
|
"name": "Read",
|
|
"input": {"file_path": "/repo/payments.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "tail-read",
|
|
"content": _PY_FIXTURE,
|
|
}
|
|
],
|
|
},
|
|
]
|
|
result = transform.apply(messages, tokenizer, frozen_message_count=2)
|
|
# Tail re-read preserved (not outlined) because the frozen prefix
|
|
# already exposed the file.
|
|
tail_content = result.messages[3]["content"][0]["content"]
|
|
assert "outlined by ast-grep" not in tail_content
|
|
assert "def process_payment" in tail_content
|
|
assert "subtotal = compute_subtotal(items)" in tail_content
|
|
|
|
|
|
def test_transform_adapter_tokens_before_is_baseline_not_reconstruction(tokenizer):
|
|
"""tokens_before must reflect the real original messages, not back-calc."""
|
|
transform = ToolResultInterceptorTransform()
|
|
messages = [
|
|
{"role": "user", "content": [{"type": "text", "text": "plain non-tool message"}]},
|
|
]
|
|
result = transform.apply(messages, tokenizer)
|
|
# No spans, no change.
|
|
assert result.tokens_before == result.tokens_after
|
|
assert result.transforms_applied == []
|
|
|
|
|
|
def test_proxy_pipeline_includes_interceptor_when_env_enabled(monkeypatch):
|
|
"""When HEADROOM_INTERCEPT_ENABLED=1, ToolResultInterceptorTransform is at index 0 in both pipelines."""
|
|
monkeypatch.setenv("HEADROOM_INTERCEPT_ENABLED", "1")
|
|
from headroom.proxy.interceptors import ToolResultInterceptorTransform
|
|
from headroom.proxy.models import ProxyConfig
|
|
from headroom.proxy.server import HeadroomProxy
|
|
|
|
proxy = HeadroomProxy(ProxyConfig())
|
|
for pipeline in (proxy.anthropic_pipeline, proxy.openai_pipeline):
|
|
transforms = pipeline.transforms
|
|
assert len(transforms) > 0
|
|
assert isinstance(transforms[0], ToolResultInterceptorTransform)
|
|
|
|
|
|
def test_proxy_pipeline_excludes_interceptor_when_env_not_set(monkeypatch):
|
|
"""When HEADROOM_INTERCEPT_ENABLED is unset, no interceptor in either pipeline."""
|
|
monkeypatch.delenv("HEADROOM_INTERCEPT_ENABLED", raising=False)
|
|
from headroom.proxy.interceptors import ToolResultInterceptorTransform
|
|
from headroom.proxy.models import ProxyConfig
|
|
from headroom.proxy.server import HeadroomProxy
|
|
|
|
proxy = HeadroomProxy(ProxyConfig())
|
|
for pipeline in (proxy.anthropic_pipeline, proxy.openai_pipeline):
|
|
transforms = pipeline.transforms
|
|
assert not any(isinstance(t, ToolResultInterceptorTransform) for t in transforms)
|