headroom/tests/test_proxy_responses_phase_preservation.py
chopratejas 967b0db439 fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents
Phase B step 1 of the live-zone-only realignment. Removes ~10K LOC of
"drop messages from history" machinery that became unreachable after
PR-A1 made `/v1/messages` a passthrough on the proxy. Live-zone-only
compression (PR-B2..B7) operates on content blocks within messages;
message-list mutation no longer happens in the pipeline.

Python deletes:
- headroom/transforms/intelligent_context.py (1077 LOC)
- headroom/transforms/rolling_window.py (395 LOC)
- headroom/transforms/progressive_summarizer.py (508 LOC)
- headroom/transforms/scoring.py (459 LOC)
- headroom/transforms/tool_crusher.py (338 LOC)
- 5 corresponding tests/test_transforms/* and tests/test_proxy_intelligent_context.py

Rust deletes:
- crates/headroom-core/src/context/* (manager, config, workspace,
  candidate, ccr_drop, strategy/, mod) + safety.rs replaced
- crates/headroom-core/src/scoring/* (mod, score, scorer, traits, weights)
- MessageScorerComparator from crates/headroom-parity (PR #338/#343
  becomes deletable; sunk cost stays sunk)
- 13 message_scorer fixtures + record_message_scorer.py

Rust adds (move + rewrite):
- crates/headroom-core/src/transforms/safety.rs — `tool_pair_indices`
  preserves the OpenAI/Anthropic tool_use ↔ tool_result pairing rule
  the live-zone dispatcher (PR-B2) needs. No IcmConfig dependency.

Surface refactors:
- HeadroomConfig: drop `tool_crusher`, `rolling_window`,
  `intelligent_context` fields; hoist `output_buffer_tokens` to top
  level (used by client.py).
- ProxyConfig: drop `intelligent_context*` fields.
- `headroom wrap` proxy server: retire IntelligentContextManager
  and RollingWindow imports + branch; pipeline is CacheAligner →
  ContentRouter (smart_routing) or CacheAligner → SmartCrusher
  (legacy).
- CLI: drop `--no-intelligent-context`, `--no-intelligent-scoring`,
  `--no-compress-first` flags.
- LangChain memory integration: rename `_apply_rolling_window` →
  `_apply_compression`, drop RollingWindowConfig dep. Threshold is
  now advisory — B6 will rework the contract.
- TransformPipeline.create_pipeline now takes only cache_aligner_config.
- headroom/__init__.py + headroom/transforms/__init__.py: strip
  exports of deleted symbols.

Bug fixes uncovered by full pytest sweep:
- providers/copilot/wrap.py: `environ or os.environ` collapsed
  empty-dict to falsy → callers passing `environ={}` accidentally
  pulled from os.environ. Use `environ if environ is not None else
  os.environ`.

Test correctness fixes:
- _DummyAnthropicHandler._retry_request gains **_kwargs to match
  the real handler signature post-A8.
- test_ws_http_fallback extracts JSON from `content=` (post-A3
  byte-faithful) rather than the obsolete `json=` kwarg.
- test_ccr_response_handler_extra fixture joins SSE events with
  `\n\n` per spec (post-A8 byte-buffer parser requirement).
- test_proxy_responses_phase_preservation: capture via direct
  handler attached to the named logger, so the assertion is
  order-independent (proxy `_setup_file_logging` flips
  `headroom.propagate=False` once any earlier test triggers it).
- conftest.py autouse fixture resets `headroom.propagate=True`
  before each test as a defensive measure for the same pollution.
- test_wrap_copilot_translated_backend_still_requires_byok:
  monkeypatch.delenv every provider key so the BYOK error
  actually fires.
- test_native_installers: skip when system bash < 4.3 (macOS ships 3.2).
- TestGeminiEmbedContent / TestGeminiBatchEmbedContents:
  pytest.mark.skip — proxy currently has no :embedContent route;
  feature gap, not regression.

Acceptance:
- cargo build --workspace + cargo clippy + cargo fmt --check: green.
- cargo test --workspace --exclude headroom-py: 777 passed.
- pytest: 4892 passed, 240 skipped, 0 failed.
- git grep returns only intentional comments referencing the deletion.

Per-PR-B1 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:23:17 -07:00

148 lines
5.6 KiB
Python

"""PR-A8 / P0-7 + P4-44 + P4-47: Responses API converter contract.
These tests pin:
- The Codex-emitted ``phase`` field (e.g. ``commentary`` /
``final_answer``) survives a forward + reverse trip through the
Chat-Completions normalizer. Pre-A8 the field was preserved only
*accidentally* via ``copy.copy(original)``; one refactor away from
silent data loss.
- Multi-text-part rebuild collapses to a single text part on the way
back instead of doubling content (the P0-7 bug).
- Unknown item types log a structured warning (P4-47) so operators see
new Codex item types in flight.
"""
from __future__ import annotations
import json
import logging
from headroom.proxy.responses_converter import (
messages_to_responses_items,
responses_items_to_messages,
)
def test_codex_phase_commentary_preserved_through_compression() -> None:
"""`phase: commentary` survives forward + reverse conversion."""
items = [
{
"type": "message",
"id": "msg_codex_1",
"role": "assistant",
"phase": "commentary",
"content": [{"type": "output_text", "text": "Here's my reasoning."}],
},
]
messages, preserved = responses_items_to_messages(items)
assert preserved == []
assert len(messages) == 1
# Pretend the pipeline compressed nothing — feed messages back.
rebuilt = messages_to_responses_items(messages, items, preserved)
assert len(rebuilt) == 1
out = rebuilt[0]
assert out["phase"] == "commentary"
assert out["id"] == "msg_codex_1"
assert out["role"] == "assistant"
def test_codex_phase_final_answer_preserved() -> None:
"""`phase: final_answer` survives even when content is compressed shorter."""
items = [
{
"type": "message",
"id": "msg_codex_2",
"role": "assistant",
"phase": "final_answer",
"content": [{"type": "output_text", "text": "Long original answer ..."}],
},
]
messages, preserved = responses_items_to_messages(items)
# Simulate compression: replace the message content with a shorter string.
messages[0]["content"] = "Short."
rebuilt = messages_to_responses_items(messages, items, preserved)
assert rebuilt[0]["phase"] == "final_answer"
# Content was replaced inside the original part structure.
assert rebuilt[0]["content"][0]["text"] == "Short."
def test_unknown_item_type_logs_warning_byte_equal() -> None:
"""Unknown item types preserve the item AND log a structured warning.
Capture is done by attaching a handler directly to the named logger
rather than relying on `caplog`. Other tests in the suite (proxy
file-logging setup) flip `headroom.*.propagate = False`, which breaks
pytest's root-attached caplog handler in unrelated test runs. A
direct handler is order-independent.
"""
items = [
{
"type": "apply_patch_v4a",
"id": "patch_1",
"patch": "--- a\n+++ b\n",
},
]
target = logging.getLogger("headroom.proxy.responses_converter")
captured: list[logging.LogRecord] = []
class _CaptureHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
captured.append(record)
handler = _CaptureHandler(level=logging.WARNING)
prev_level = target.level
target.addHandler(handler)
target.setLevel(logging.WARNING)
try:
messages, preserved = responses_items_to_messages(items, request_id="req-xyz")
finally:
target.removeHandler(handler)
target.setLevel(prev_level)
# Item is preserved (byte-equal) on the rebuild side.
assert preserved == [0]
rebuilt = messages_to_responses_items(messages, items, preserved)
assert rebuilt == items
# A structured warning fired with the unknown type.
matched = [r for r in captured if "unknown_responses_item_type" in r.getMessage()]
assert matched, "expected unknown_responses_item_type warning log line"
msg = matched[0].getMessage()
assert "apply_patch_v4a" in msg
assert "patch_1" in msg
assert "req-xyz" in msg
def test_multi_text_part_rebuild_no_doubling() -> None:
"""Two-text-part input is collapsed to a single part on rebuild — no doubling."""
items = [
{
"type": "message",
"id": "msg_multi",
"role": "user",
"content": [
{"type": "input_text", "text": "First paragraph."},
{"type": "input_text", "text": "Second paragraph."},
],
},
]
messages, preserved = responses_items_to_messages(items)
# The two text parts join with `\n`.
assert messages[0]["content"] == "First paragraph.\nSecond paragraph."
# Pretend compression replaced with a shortened single string.
messages[0]["content"] = "Para one. Para two."
rebuilt = messages_to_responses_items(messages, items, preserved)
out_content = rebuilt[0]["content"]
# Critical: there is now exactly ONE text part on the rebuild,
# carrying the compressed string. The previous bug left a
# second part with the ORIGINAL "Second paragraph." text and
# the compressed string in part 0 — content doubled on the wire.
assert isinstance(out_content, list)
text_parts = [p for p in out_content if p.get("type") == "input_text"]
assert len(text_parts) == 1, (
f"expected exactly 1 text part on rebuild, got {len(text_parts)}"
f"this is the P0-7 doubling bug regression"
)
assert text_parts[0]["text"] == "Para one. Para two."
# And the round-trip JSON is well-formed.
assert json.dumps(rebuilt)