feat: Claude Code context injection (UserPromptSubmit hook)
Closes the asymmetry the user surfaced: before this, Claude Code captured every turn (Stop hook) but retrieval only happened when Claude chose to call atocore_context (opt-in MCP tool). OpenClaw had both sides covered after 7I; Claude Code did not. Now symmetric. Every Claude Code prompt is auto-sent to /context/build and the returned pack is prepended via hookSpecificOutput.additionalContext — same as what OpenClaw's before_agent_start hook now does. - deploy/hooks/inject_context.py — UserPromptSubmit hook. Fail-open (always exit 0). Skips short/XML prompts. 5s timeout. Project inference mirrors capture_stop.py cwd→slug table. Kill switch: ATOCORE_CONTEXT_DISABLED=1. - ~/.claude/settings.json registered the hook (local config, not committed; copy-paste snippet in docs/capture-surfaces.md). - Removed /wiki/capture from topnav. Endpoint still exists but the page is now labeled "fallback only" with a warning banner. The sanctioned surfaces are Claude Code + OpenClaw; manual paste is explicitly not the design. - docs/capture-surfaces.md — scope statement: two surfaces, nothing else. Anthropic API polling explicitly prohibited. Tests: +8 for inject_context.py (exit 0 on all failure modes, kill switch, short prompt filter, XML filter, bad stdin, mock-server success shape, project inference from cwd). Updated 2 wiki tests for the topnav change. 450 → 459. Verified live with real AtoCore: injected 2979 chars of atocore project context on a cwd-matched prompt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
tests/test_inject_context_hook.py
Normal file
198
tests/test_inject_context_hook.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Tests for deploy/hooks/inject_context.py — Claude Code UserPromptSubmit hook.
|
||||
|
||||
These are process-level tests: we run the actual script with subprocess,
|
||||
feed it stdin, and check the exit code + stdout shape. The hook must:
|
||||
- always exit 0 (never block a user prompt)
|
||||
- emit valid hookSpecificOutput JSON on success
|
||||
- fail open (empty output) on network errors, bad stdin, kill-switch
|
||||
- respect the short-prompt filter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HOOK = Path(__file__).resolve().parent.parent / "deploy" / "hooks" / "inject_context.py"
|
||||
|
||||
|
||||
def _run_hook(stdin_json: dict | str, env_overrides: dict | None = None, timeout: float = 10) -> tuple[int, str, str]:
|
||||
env = os.environ.copy()
|
||||
# Force kill switch off unless the test overrides
|
||||
env.pop("ATOCORE_CONTEXT_DISABLED", None)
|
||||
if env_overrides:
|
||||
env.update(env_overrides)
|
||||
stdin = stdin_json if isinstance(stdin_json, str) else json.dumps(stdin_json)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(HOOK)],
|
||||
input=stdin, text=True,
|
||||
capture_output=True, timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def test_hook_exit_0_on_success_or_failure():
|
||||
"""Canonical contract: the hook never blocks a prompt. Even with a
|
||||
bogus URL we must exit 0 with empty stdout (fail-open)."""
|
||||
code, stdout, stderr = _run_hook(
|
||||
{
|
||||
"prompt": "What's the p04-gigabit current status?",
|
||||
"cwd": "/tmp",
|
||||
"session_id": "t",
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
},
|
||||
env_overrides={"ATOCORE_URL": "http://127.0.0.1:1", # unreachable
|
||||
"ATOCORE_CONTEXT_TIMEOUT": "1"},
|
||||
)
|
||||
assert code == 0
|
||||
# stdout is empty (fail-open) — no hookSpecificOutput emitted
|
||||
assert stdout.strip() == ""
|
||||
assert "atocore unreachable" in stderr or "request failed" in stderr
|
||||
|
||||
|
||||
def test_hook_kill_switch():
|
||||
code, stdout, stderr = _run_hook(
|
||||
{"prompt": "hello world is this a thing", "cwd": "", "session_id": "t"},
|
||||
env_overrides={"ATOCORE_CONTEXT_DISABLED": "1"},
|
||||
)
|
||||
assert code == 0
|
||||
assert stdout.strip() == ""
|
||||
|
||||
|
||||
def test_hook_ignores_short_prompt():
|
||||
code, stdout, _ = _run_hook(
|
||||
{"prompt": "ok", "cwd": "", "session_id": "t"},
|
||||
env_overrides={"ATOCORE_URL": "http://127.0.0.1:1"},
|
||||
)
|
||||
assert code == 0
|
||||
# No network call attempted; empty output
|
||||
assert stdout.strip() == ""
|
||||
|
||||
|
||||
def test_hook_ignores_xml_prompt():
|
||||
"""System/meta prompts starting with '<' should be skipped."""
|
||||
code, stdout, _ = _run_hook(
|
||||
{"prompt": "<system>do something</system>", "cwd": "", "session_id": "t"},
|
||||
env_overrides={"ATOCORE_URL": "http://127.0.0.1:1"},
|
||||
)
|
||||
assert code == 0
|
||||
assert stdout.strip() == ""
|
||||
|
||||
|
||||
def test_hook_handles_bad_stdin():
|
||||
code, stdout, stderr = _run_hook("not-json-at-all")
|
||||
assert code == 0
|
||||
assert stdout.strip() == ""
|
||||
assert "bad stdin" in stderr
|
||||
|
||||
|
||||
def test_hook_handles_empty_stdin():
|
||||
code, stdout, _ = _run_hook("")
|
||||
assert code == 0
|
||||
assert stdout.strip() == ""
|
||||
|
||||
|
||||
def test_hook_success_shape_with_mock_server(monkeypatch, tmp_path):
|
||||
"""When the API returns a pack, the hook emits valid
|
||||
hookSpecificOutput JSON wrapping it."""
|
||||
# Start a tiny HTTP server on localhost that returns a fake pack
|
||||
import http.server
|
||||
import json as _json
|
||||
import threading
|
||||
|
||||
pack = "Trusted State: foo=bar"
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
body = _json.dumps({"formatted_context": pack}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a, **kw):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
code, stdout, stderr = _run_hook(
|
||||
{
|
||||
"prompt": "What do we know about p04?",
|
||||
"cwd": "",
|
||||
"session_id": "t",
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
},
|
||||
env_overrides={
|
||||
"ATOCORE_URL": f"http://127.0.0.1:{port}",
|
||||
"ATOCORE_CONTEXT_TIMEOUT": "5",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
assert code == 0, stderr
|
||||
assert stdout.strip(), "expected JSON output with context"
|
||||
out = json.loads(stdout)
|
||||
hso = out.get("hookSpecificOutput", {})
|
||||
assert hso.get("hookEventName") == "UserPromptSubmit"
|
||||
assert pack in hso.get("additionalContext", "")
|
||||
assert "AtoCore-injected context" in hso.get("additionalContext", "")
|
||||
|
||||
|
||||
def test_hook_project_inference_from_cwd(monkeypatch):
|
||||
"""The hook should map a known cwd to a project slug and send it in
|
||||
the /context/build payload."""
|
||||
import http.server
|
||||
import json as _json
|
||||
import threading
|
||||
|
||||
captured_body: dict = {}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n)
|
||||
captured_body.update(_json.loads(body.decode()))
|
||||
out = _json.dumps({"formatted_context": "ok"}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(out)))
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
|
||||
def log_message(self, *a, **kw):
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
_run_hook(
|
||||
{
|
||||
"prompt": "Is this being tested properly",
|
||||
"cwd": "C:\\Users\\antoi\\ATOCore",
|
||||
"session_id": "t",
|
||||
},
|
||||
env_overrides={
|
||||
"ATOCORE_URL": f"http://127.0.0.1:{port}",
|
||||
"ATOCORE_CONTEXT_TIMEOUT": "5",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
# Hook should have inferred project="atocore" from the ATOCore cwd
|
||||
assert captured_body.get("project") == "atocore"
|
||||
assert captured_body.get("prompt", "").startswith("Is this being tested")
|
||||
@@ -30,17 +30,23 @@ def _init_all():
|
||||
init_engineering_schema()
|
||||
|
||||
|
||||
def test_capture_page_renders(tmp_data_dir):
|
||||
def test_capture_page_renders_as_fallback(tmp_data_dir):
|
||||
_init_all()
|
||||
html = render_capture()
|
||||
assert "Capture a conversation" in html
|
||||
# Page is reachable but now labeled as a fallback, not promoted
|
||||
assert "fallback only" in html
|
||||
assert "sanctioned capture surfaces are Claude Code" in html
|
||||
# Form inputs still exist for emergency use
|
||||
assert "cap-prompt" in html
|
||||
assert "cap-response" in html
|
||||
# Topnav present
|
||||
assert "topnav" in html
|
||||
# Source options for mobile/desktop
|
||||
assert "claude-desktop" in html
|
||||
assert "claude-mobile" in html
|
||||
|
||||
|
||||
def test_capture_not_in_topnav(tmp_data_dir):
|
||||
"""The paste form should NOT appear in topnav — it's not the sanctioned path."""
|
||||
_init_all()
|
||||
html = render_homepage()
|
||||
assert "/wiki/capture" not in html
|
||||
assert "📥 Capture" not in html
|
||||
|
||||
|
||||
def test_memory_detail_renders(tmp_data_dir):
|
||||
@@ -125,12 +131,11 @@ def test_homepage_has_topnav_and_activity(tmp_data_dir):
|
||||
_init_all()
|
||||
create_memory("knowledge", "homepage test")
|
||||
html = render_homepage()
|
||||
# Topnav with expected items
|
||||
# Topnav with expected items (Capture removed — it's not sanctioned capture)
|
||||
assert "🏠 Home" in html
|
||||
assert "📡 Activity" in html
|
||||
assert "📥 Capture" in html
|
||||
assert "/wiki/capture" in html
|
||||
assert "/wiki/activity" in html
|
||||
assert "/wiki/capture" not in html
|
||||
# Activity snippet
|
||||
assert "What the brain is doing" in html
|
||||
|
||||
|
||||
Reference in New Issue
Block a user