fleet-memory/hindsight-integrations/codex/tests/test_hooks.py
Ben 0b17a67c70
feat: add Hindsight memory integration for OpenAI Codex CLI (#730)
* feat(codex): add Hindsight memory integration for OpenAI Codex CLI

Hooks-based integration that gives Codex CLI long-term memory via Hindsight.
Three hooks keep memory in sync: SessionStart (daemon pre-warm), UserPromptSubmit
(recall + context injection), Stop (retain conversation to memory).

Key differences from the Claude Code integration:
- Codex transcript format: JSONL with {msg: {type, message}} (user_message/agent_message)
- No CODEX_PLUGIN_ROOT env var — install.sh writes hooks.json with absolute paths
- State stored in ~/.hindsight/codex/state/ (not CLAUDE_PLUGIN_DATA)
- No async: true in hooks (not supported by Codex)
- No SessionEnd event
- hooks.json written to ~/.codex/hooks.json with codex_hooks = true in config.toml

* fix(codex): fix transcript parser for actual Codex disk format

Codex stores sessions as rollout-*.jsonl with response_item entries:
  User:      {type:response_item, payload:{type:message, role:user, content:[{type:input_text, text:...}]}}
  Assistant: {type:response_item, payload:{type:message, role:assistant, phase:final_answer, content:[{type:output_text, text:...}]}}

Previous parser expected an undocumented {msg:{type:user_message}} format from the Rust protocol spec
that does not match the actual on-disk storage format.

* feat(codex): add reflect mode to UserPromptSubmit hook

Add recallMode config option (default: 'recall') that switches the
UserPromptSubmit hook between:
- 'recall': existing behavior, fast raw facts list
- 'reflect': agentic synthesis loop, returns coherent prose answer

Also adds reflect() method to HindsightClient and HINDSIGHT_RECALL_MODE
env var override. Reflect uses a 25s timeout (vs 10s for recall).

* feat(codex): auto mode for recall/reflect selection

Add recallMode: 'auto' (new default) that picks the operation per-query:
- Synthesis patterns (what do you know, what's my, summarize, etc.) → reflect
- All other prompts → recall (fast, raw facts, better for code tasks)

* feat(codex): add automated test suite and finalize recall-only mode

* docs(codex): add docs page and sidebar entry for Codex CLI integration
2026-03-30 10:51:53 +02:00

319 lines
13 KiB
Python

"""End-to-end tests for recall.py and retain.py hook scripts.
Mocks the Codex hook runtime:
- stdin → io.StringIO(json.dumps(hook_input))
- stdout → io.StringIO() captured for assertions
- urllib.request.urlopen → fake HTTP responses
- HOME → tmp_path (isolates ~/.hindsight/codex.json and state)
"""
import importlib
import io
import json
import os
import sys
from unittest.mock import patch
import pytest
from conftest import FakeHTTPResponse, make_hook_input, make_memory, make_transcript_file, make_user_config
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_hook(module_name, hook_input, monkeypatch, tmp_path, urlopen_side_effect=None, user_config=None):
"""Import and run a hook script's main() with mocked stdin/stdout/HTTP."""
# Isolate HOME so ~/.hindsight/codex.json and state land in tmp_path
monkeypatch.setenv("HOME", str(tmp_path))
# Strip real HINDSIGHT_* env vars
for k in list(os.environ):
if k.startswith("HINDSIGHT_"):
monkeypatch.delenv(k, raising=False)
# Set required API URL via env var
monkeypatch.setenv("HINDSIGHT_API_URL", "http://fake:9077")
# Write user config (enables retain on every turn + any overrides)
cfg = {"retainEveryNTurns": 1, "autoRecall": True, "autoRetain": True}
if user_config:
cfg.update(user_config)
make_user_config(tmp_path, cfg)
stdin_data = io.StringIO(json.dumps(hook_input))
stdout_capture = io.StringIO()
# Force reimport so the module picks up patched env
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
spec = importlib.util.spec_from_file_location(
module_name + "_fresh", os.path.join(scripts_dir, f"{module_name}.py")
)
mod = importlib.util.module_from_spec(spec)
default_response = FakeHTTPResponse({"results": []})
side_effect = urlopen_side_effect or (lambda *a, **kw: default_response)
with (
patch("sys.stdin", stdin_data),
patch("sys.stdout", stdout_capture),
patch("urllib.request.urlopen", side_effect=side_effect),
):
spec.loader.exec_module(mod)
mod.main()
return stdout_capture.getvalue()
# ---------------------------------------------------------------------------
# recall hook
# ---------------------------------------------------------------------------
class TestRecallHook:
def test_outputs_additional_context_when_memories_found(self, monkeypatch, tmp_path):
memory = make_memory("Paris is the capital of France", "world")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=lambda *a, **kw: response)
data = json.loads(output)
context = data["hookSpecificOutput"]["additionalContext"]
assert "Paris is the capital of France" in context
assert "<hindsight_memories>" in context
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_no_output_for_short_prompt(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hi")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_graceful_on_api_error(self, monkeypatch, tmp_path):
def raise_error(*a, **kw):
raise OSError("connection refused")
hook_input = make_hook_input(prompt="What is my project about?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
assert output.strip() == ""
def test_output_format_matches_codex_spec(self, monkeypatch, tmp_path):
memory = make_memory("User prefers Python")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What language should I use?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=lambda *a, **kw: response)
data = json.loads(output)
assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
assert "additionalContext" in data["hookSpecificOutput"]
def test_multi_turn_context_from_transcript(self, monkeypatch, tmp_path):
"""When recallContextTurns > 1, prior transcript is included in query."""
messages = [
{"role": "user", "content": "I use Python for all my scripts"},
{"role": "assistant", "content": "Noted!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured_body = {}
def capture_and_respond(req, timeout=None):
if "/recall" in req.full_url:
captured_body["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"results": []})
hook_input = make_hook_input(prompt="What language should I use?", transcript_path=transcript)
_run_hook("recall", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture_and_respond,
user_config={"recallContextTurns": 2})
if "body" in captured_body:
assert "Python" in captured_body["body"].get("query", "")
def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path,
user_config={"autoRecall": False})
assert output.strip() == ""
# ---------------------------------------------------------------------------
# retain hook
# ---------------------------------------------------------------------------
class TestRetainHook:
def test_posts_transcript_to_hindsight(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"status": "accepted"})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured, "retain API was not called"
assert "hello" in captured["body"]["items"][0]["content"]
def test_no_retain_on_empty_transcript(self, monkeypatch, tmp_path):
hook_input = make_hook_input(transcript_path="/nonexistent/transcript.jsonl")
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "called" not in captured
def test_strips_memory_tags_before_retaining(self, monkeypatch, tmp_path):
messages = [
{"role": "user", "content": "<hindsight_memories>old memories</hindsight_memories> actual question"},
{"role": "assistant", "content": "sure!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
content = captured["body"]["items"][0]["content"]
assert "old memories" not in content
assert "actual question" in content
def test_retain_posts_async_true(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
assert captured["body"].get("async") is True
def test_retain_includes_codex_context_label(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
if "body" in captured:
assert captured["body"]["items"][0]["context"] == "codex"
def test_retain_skips_below_every_n_turns_threshold(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
# retainEveryNTurns=3 — first call should be skipped
_run_hook("retain", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture,
user_config={"retainEveryNTurns": 3})
assert "called" not in captured
def test_retain_uses_session_id_as_document_id(self, monkeypatch, tmp_path):
messages = [
{"role": "user", "content": "question"}, {"role": "assistant", "content": "answer"},
]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript, session_id="sess-doc-test")
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured
assert captured["body"]["items"][0]["document_id"] == "sess-doc-test"
def test_graceful_on_retain_api_error(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "test"}, {"role": "assistant", "content": "response"}]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript)
def raise_error(req, timeout=None):
if "/memories" in req.full_url:
raise OSError("connection refused")
return FakeHTTPResponse({})
# Should not raise
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
messages = [{"role": "user", "content": "hello"}]
transcript = make_transcript_file(tmp_path, messages)
hook_input = make_hook_input(transcript_path=transcript)
captured = {}
def capture(req, timeout=None):
captured["called"] = True
return FakeHTTPResponse({})
_run_hook("retain", hook_input, monkeypatch, tmp_path,
urlopen_side_effect=capture,
user_config={"autoRetain": False})
assert "called" not in captured
def test_reads_codex_response_item_format(self, monkeypatch, tmp_path):
"""Retain should correctly parse the actual Codex on-disk transcript format."""
messages = [
{"role": "user", "content": "I like TypeScript"},
{"role": "assistant", "content": "Great choice!"},
]
transcript = make_transcript_file(tmp_path, messages, codex_format=True)
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "body" in captured, "retain API was not called"
content = captured["body"]["items"][0]["content"]
assert "TypeScript" in content