headroom/tests/test_codex_ws_per_frame_memory.py
Rod Boev 38479fcda1
fix(codex): rerun memory lookup on every response.create WS frame (#2113)
## Description

Long-lived Codex `/v1/responses` WebSocket sessions only ran memory
decision, query construction, and context injection on the first
`response.create` frame because `handle_openai_responses_ws` kept that
logic outside the relay loop. This change extracts that path into a
local helper reused before compression for every eligible
`response.create`, while keeping sticky memory tools deduplicated by the
existing session helper.

Closes #2059

## 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

- Extracted the first-frame memory preparation path into a local async
helper inside `handle_openai_responses_ws`.
- Reused that helper for the initial frame and every later eligible
`response.create` before compression and shaping.
- Added focused two-turn WebSocket regressions for per-frame lookup,
bypass and disabled-memory handling, list-shaped later inputs,
memory-handler fail-open recovery, and sticky-tool replay.
- Raised the locked production floors for `click` and `pillow` to clear
the current `pip-audit` findings that now fail external PR merge
snapshots.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_codex_ws_per_frame_memory.py
tests/test_openai_codex_ws_lifecycle.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/openai.py
tests/test_codex_ws_per_frame_memory.py
tests/test_openai_codex_ws_lifecycle.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run pytest tests/test_codex_ws_per_frame_memory.py tests/test_openai_codex_ws_lifecycle.py -q

============================= test session starts =============================
platform win32 -- Python 3.12.13, pytest-9.0.3, pluggy-1.6.0
rootdir: D:\Repos\headroom-pr-2059-codex-ws-memory-lookup
configfile: pyproject.toml
plugins: anyio-4.12.1, langsmith-0.9.3, asyncio-1.3.0, cov-7.0.0
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 35 items

tests\test_codex_ws_per_frame_memory.py .........                        [ 25%]
tests\test_openai_codex_ws_lifecycle.py ..........................       [100%]

============================= 35 passed in 1.89s ==============================

uv run ruff check headroom/proxy/handlers/openai.py tests/test_codex_ws_per_frame_memory.py tests/test_openai_codex_ws_lifecycle.py
All checks passed!

uv run ruff format headroom/proxy/handlers/openai.py tests/test_codex_ws_per_frame_memory.py tests/test_openai_codex_ws_lifecycle.py --check
3 files already formatted
```

## Real Behavior Proof

- Environment: Windows, Python 3.12.13, in-process WebSocket harness
with a fake memory handler
- Exact command / steps: `uv run pytest
tests/test_codex_ws_per_frame_memory.py
tests/test_openai_codex_ws_lifecycle.py -q`, which opens one connection,
sends distinct `response.create` frames, and records each memory lookup
plus the forwarded tool set
- Observed result: the handler performs one lookup per eligible frame,
later-frame compression sees current-turn memory-prepared input, and
both forwarded turns carry the same deduplicated `memory_search` and
`memory_save` definitions
- Not tested: live Codex subscription WebSocket

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Security CI currently flags `click 8.3.1` and `pillow 12.2.0`, so this
PR carries the narrow floor bump to `click>=8.3.3` and `pillow>=12.3.0`
as a supply-chain unblock for the same final merge snapshot.
- `CHANGELOG.md` remains untouched because Headroom generates release
notes from conventional commits.
- This PR is scoped to the OpenAI Responses WebSocket relay lifecycle;
it does not change the HTTP `/v1/responses` path or the Anthropic
handler.

---------

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 14:42:27 -04:00

411 lines
14 KiB
Python

from __future__ import annotations
import asyncio
import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from tests.test_openai_codex_ws_lifecycle import (
_DummyOpenAIHandler,
_FakeUpstream,
_FakeWebSocket,
_make_fake_websockets_module,
)
class _MemoryHandler:
def __init__(self) -> None:
self.config = SimpleNamespace(
inject_context=True,
inject_tools=True,
project_root_override="",
)
self.queries: list[str] = []
async def search_and_format_context(self, _user_id, messages, **_kwargs):
current_turn = messages[-1]["content"] if messages else ""
self.queries.append(current_turn)
return f"current memory: {current_turn}"
def compute_memory_tool_definitions(self, _provider):
return [
{
"type": "function",
"function": {
"name": "memory_search",
"description": "search",
"parameters": {"type": "object"},
},
},
{
"type": "function",
"function": {
"name": "memory_save",
"description": "save",
"parameters": {"type": "object"},
},
},
]
def _expected_memory_response_tools() -> list[dict[str, object]]:
expected: list[dict[str, object]] = []
for tool in _MemoryHandler().compute_memory_tool_definitions("openai"):
function = tool["function"]
expected.append(
{
"type": "function",
"name": function["name"],
"description": function["description"],
"parameters": function["parameters"],
}
)
return expected
def _turn(text: str) -> str:
return json.dumps({"type": "response.create", "response": {"input": text}})
def _direct_turn(text: str) -> str:
return json.dumps({"input": text})
def _issue_2059_artifact_path() -> Path:
return Path(__file__).resolve().parent / "fixtures" / "issues" / "headroom_issue_2059.json"
def _issue_2059_turns() -> tuple[str, str]:
issue_path = _issue_2059_artifact_path()
issue = json.loads(issue_path.read_text(encoding="utf-8"))
match = re.search(r"```json\s*(.*?)```", issue["body"], re.DOTALL)
assert match is not None, "issue 2059 artifact must contain a JSON code sample"
frames = [line.strip() for line in match.group(1).splitlines() if line.strip()]
assert len(frames) == 2, "issue 2059 artifact must contain exactly two frames"
return frames[0], frames[1]
def _issue_2059_inputs() -> tuple[str, str]:
first, later = _issue_2059_turns()
return (
json.loads(first)["response"]["input"],
json.loads(later)["response"]["input"],
)
def _list_turn(text: str, *, instructions: str) -> str:
return json.dumps(
{
"type": "response.create",
"response": {
"instructions": instructions,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": text}],
}
],
},
}
)
class _FlakyMemoryHandler(_MemoryHandler):
def __init__(self, *, fail_on: set[str]) -> None:
super().__init__()
self.fail_on = set(fail_on)
async def search_and_format_context(self, _user_id, messages, **_kwargs):
current_turn = messages[-1]["content"] if messages else ""
self.queries.append(current_turn)
if current_turn in self.fail_on:
raise RuntimeError(f"memory failed for {current_turn}")
return f"current memory: {current_turn}"
class _ToolFailingMemoryHandler(_MemoryHandler):
def __init__(self) -> None:
super().__init__()
self._fail_next_tools = True
def compute_memory_tool_definitions(self, _provider):
if self._fail_next_tools:
self._fail_next_tools = False
raise RuntimeError("memory tool preparation failed")
return super().compute_memory_tool_definitions(_provider)
@pytest.mark.asyncio
async def test_memory_lookup_runs_for_each_issue_artifact_frame_and_preserves_non_create_frames():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first_turn, later_turn = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
client_frames = [
first_turn,
json.dumps({"type": "response.cancel"}),
later_turn,
]
client_ws = _FakeWebSocket(frames=client_frames)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [first_input, later_input]
assert upstream.sent[1] == client_frames[1]
forwarded_turns = [
json.loads(frame) for frame in upstream.sent if "response" in json.loads(frame)
]
assert f"current memory: {first_input}" in forwarded_turns[0]["response"]["input"]
assert f"current memory: {later_input}" in forwarded_turns[1]["response"]["input"]
expected_tools = _expected_memory_response_tools()
for frame in forwarded_turns:
assert frame["response"]["tools"] == expected_tools
assert forwarded_turns[0]["response"]["tools"] == forwarded_turns[1]["response"]["tools"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_input_bearing_non_create_first_frame():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
_first_input, later_input = _issue_2059_inputs()
cancel_frame = json.dumps(
{
"type": "response.cancel",
"response_id": "r_1",
"input": "must not query",
}
)
later_turn = _issue_2059_turns()[1]
client_ws = _FakeWebSocket(frames=[cancel_frame, later_turn])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [later_input]
assert upstream.sent[0] == cancel_frame
forwarded_later = json.loads(upstream.sent[1])
assert f"current memory: {later_input}" in forwarded_later["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_bypassed_frames():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first, later = _issue_2059_turns()
client_ws = _FakeWebSocket(
frames=[first, later],
headers={"authorization": "Bearer test", "x-headroom-bypass": "true"},
)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == []
assert upstream.sent == [first, later]
@pytest.mark.asyncio
async def test_memory_lookup_keeps_legacy_direct_first_frame():
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first_input, later_input = _issue_2059_inputs()
first = _direct_turn(first_input)
later = _issue_2059_turns()[1]
client_ws = _FakeWebSocket(frames=[first, later])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == [first_input, later_input]
forwarded_first = json.loads(upstream.sent[0])
forwarded_later = json.loads(upstream.sent[1])
assert f"current memory: {first_input}" in forwarded_first["input"]
assert f"current memory: {later_input}" in forwarded_later["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_skips_disabled_memory(monkeypatch):
monkeypatch.setenv("HEADROOM_MEMORY_INJECTION_MODE", "disabled")
upstream = _FakeUpstream(
[
json.dumps({"type": "response.created", "response": {"id": "r_1"}}),
json.dumps({"type": "response.completed", "response": {"id": "r_1"}}),
]
)
first, later = _issue_2059_turns()
client_ws = _FakeWebSocket(frames=[first, later])
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
await handler.handle_openai_responses_ws(client_ws)
assert memory.queries == []
assert upstream.sent == [first, later]
@pytest.mark.asyncio
async def test_memory_lookup_fails_open_and_recovers_on_later_frame():
first, later = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _FlakyMemoryHandler(fail_on={first_input})
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert memory.queries == [first_input, later_input]
assert upstream.sent[0] == first
assert f"current memory: {later_input}" in json.loads(upstream.sent[1])["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_fails_open_when_tool_preparation_raises():
first, later = _issue_2059_turns()
first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _ToolFailingMemoryHandler()
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert memory.queries == [first_input, later_input]
assert upstream.sent[0] == first
assert f"current memory: {later_input}" in json.loads(upstream.sent[1])["response"]["input"]
@pytest.mark.asyncio
async def test_memory_lookup_preserves_list_shaped_later_frame_input():
first, _later = _issue_2059_turns()
list_frame = _list_turn(
"later turn with list payload",
instructions="list payload instructions",
)
expected_input = json.loads(list_frame)["response"]["input"]
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, list_frame], hold_after_initial=True)
handler = _DummyOpenAIHandler()
memory = _MemoryHandler()
handler.memory_handler = memory
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
forwarded_later = json.loads(upstream.sent[1])
assert forwarded_later["response"]["input"] == expected_input
assert memory.queries[-1] == "list payload instructions"
@pytest.mark.asyncio
async def test_later_frame_compression_receives_memory_prepared_input():
first, later = _issue_2059_turns()
_first_input, later_input = _issue_2059_inputs()
upstream = _FakeUpstream([], hold_after_events=True)
client_ws = _FakeWebSocket(frames=[first, later], hold_after_initial=True)
handler = _DummyOpenAIHandler()
handler.config.optimize = True
memory = _MemoryHandler()
handler.memory_handler = memory
seen_inputs: list[object] = []
def _capture_compress(payload, *, model, request_id, timing=None):
seen_inputs.append(payload["input"])
return payload, False, 0, [], "test_noop", 10, 10, 0
async def _trigger() -> None:
await asyncio.sleep(0.05)
client_ws.trigger_disconnect()
handler._compress_openai_responses_payload = _capture_compress # type: ignore[method-assign]
with patch.dict(sys.modules, {"websockets": _make_fake_websockets_module(upstream)}):
trigger_task = asyncio.create_task(_trigger())
try:
await handler.handle_openai_responses_ws(client_ws)
finally:
trigger_task.cancel()
try:
await trigger_task
except asyncio.CancelledError:
pass
assert len(seen_inputs) == 2
assert f"current memory: {later_input}" in str(seen_inputs[1])