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:
174
deploy/hooks/inject_context.py
Normal file
174
deploy/hooks/inject_context.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Claude Code UserPromptSubmit hook: inject AtoCore context.
|
||||||
|
|
||||||
|
Mirrors the OpenClaw 7I pattern on the Claude Code side. Every user
|
||||||
|
prompt submitted to Claude Code is (a) sent to /context/build on the
|
||||||
|
AtoCore API, and (b) the returned context pack is prepended to the
|
||||||
|
prompt the LLM sees — so Claude Code answers grounded in what AtoCore
|
||||||
|
already knows, same as OpenClaw now does.
|
||||||
|
|
||||||
|
Contract per Claude Code hooks spec:
|
||||||
|
stdin: JSON with `prompt`, `session_id`, `transcript_path`, `cwd`,
|
||||||
|
`hook_event_name`, etc.
|
||||||
|
stdout on success: JSON
|
||||||
|
{"hookSpecificOutput":
|
||||||
|
{"hookEventName": "UserPromptSubmit",
|
||||||
|
"additionalContext": "<pack>"}}
|
||||||
|
exit 0 always — fail open. An unreachable AtoCore must never block
|
||||||
|
the user's prompt.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
ATOCORE_URL base URL (default http://dalidou:8100)
|
||||||
|
ATOCORE_CONTEXT_DISABLED set to "1" to disable injection
|
||||||
|
ATOCORE_CONTEXT_BUDGET max chars of injected pack (default 4000)
|
||||||
|
ATOCORE_CONTEXT_TIMEOUT HTTP timeout in seconds (default 5)
|
||||||
|
|
||||||
|
Usage in ~/.claude/settings.json:
|
||||||
|
"UserPromptSubmit": [{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python /path/to/inject_context.py",
|
||||||
|
"timeout": 10
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100")
|
||||||
|
CONTEXT_TIMEOUT = float(os.environ.get("ATOCORE_CONTEXT_TIMEOUT", "5"))
|
||||||
|
CONTEXT_BUDGET = int(os.environ.get("ATOCORE_CONTEXT_BUDGET", "4000"))
|
||||||
|
|
||||||
|
# Don't spend an API call on trivial acks or slash commands.
|
||||||
|
MIN_PROMPT_LENGTH = 15
|
||||||
|
|
||||||
|
|
||||||
|
# Project inference table — kept in sync with capture_stop.py so both
|
||||||
|
# hooks agree on what project a Claude Code session belongs to.
|
||||||
|
_VAULT = "C:\\Users\\antoi\\antoine\\My Libraries\\Antoine Brain Extension"
|
||||||
|
_PROJECT_PATH_MAP: dict[str, str] = {
|
||||||
|
f"{_VAULT}\\2-Projects\\P04-GigaBIT-M1": "p04-gigabit",
|
||||||
|
f"{_VAULT}\\2-Projects\\P10-Interferometer": "p05-interferometer",
|
||||||
|
f"{_VAULT}\\2-Projects\\P11-Polisher-Fullum": "p06-polisher",
|
||||||
|
f"{_VAULT}\\2-Projects\\P08-ABB-Space-Mirror": "abb-space",
|
||||||
|
f"{_VAULT}\\2-Projects\\I01-Atomizer": "atomizer-v2",
|
||||||
|
f"{_VAULT}\\2-Projects\\I02-AtoCore": "atocore",
|
||||||
|
"C:\\Users\\antoi\\ATOCore": "atocore",
|
||||||
|
"C:\\Users\\antoi\\Polisher-Sim": "p06-polisher",
|
||||||
|
"C:\\Users\\antoi\\Fullum-Interferometer": "p05-interferometer",
|
||||||
|
"C:\\Users\\antoi\\Atomizer-V2": "atomizer-v2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_project(cwd: str) -> str:
|
||||||
|
if not cwd:
|
||||||
|
return ""
|
||||||
|
norm = os.path.normpath(cwd).lower()
|
||||||
|
for path_prefix, project_id in _PROJECT_PATH_MAP.items():
|
||||||
|
if norm.startswith(os.path.normpath(path_prefix).lower()):
|
||||||
|
return project_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_empty() -> None:
|
||||||
|
"""Exit 0 with no additionalContext — equivalent to no-op."""
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_context(pack: str) -> None:
|
||||||
|
"""Write the hook output JSON and exit 0."""
|
||||||
|
out = {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "UserPromptSubmit",
|
||||||
|
"additionalContext": pack,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(out))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if os.environ.get("ATOCORE_CONTEXT_DISABLED") == "1":
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
if not raw.strip():
|
||||||
|
_emit_empty()
|
||||||
|
hook_data = json.loads(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
# Bad stdin → nothing to do
|
||||||
|
print(f"inject_context: bad stdin: {exc}", file=sys.stderr)
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
prompt = (hook_data.get("prompt") or "").strip()
|
||||||
|
cwd = hook_data.get("cwd", "")
|
||||||
|
|
||||||
|
if len(prompt) < MIN_PROMPT_LENGTH:
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
# Skip meta / system prompts that start with '<' (XML tags etc.)
|
||||||
|
if prompt.startswith("<"):
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
project = _infer_project(cwd)
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"prompt": prompt,
|
||||||
|
"project": project,
|
||||||
|
"char_budget": CONTEXT_BUDGET,
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ATOCORE_URL}/context/build",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=CONTEXT_TIMEOUT)
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
# AtoCore unreachable — fail open
|
||||||
|
print(f"inject_context: atocore unreachable: {exc}", file=sys.stderr)
|
||||||
|
_emit_empty()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"inject_context: request failed: {exc}", file=sys.stderr)
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
pack = (data.get("formatted_context") or "").strip()
|
||||||
|
if not pack:
|
||||||
|
_emit_empty()
|
||||||
|
|
||||||
|
# Safety truncate. /context/build respects the budget we sent, but
|
||||||
|
# be defensive in case of a regression.
|
||||||
|
if len(pack) > CONTEXT_BUDGET + 500:
|
||||||
|
pack = pack[:CONTEXT_BUDGET] + "\n\n[context truncated]"
|
||||||
|
|
||||||
|
# Wrap so the LLM knows this is injected grounding, not user text.
|
||||||
|
wrapped = (
|
||||||
|
"---\n"
|
||||||
|
"AtoCore-injected context for this prompt "
|
||||||
|
f"(project={project or '(none)'}):\n\n"
|
||||||
|
f"{pack}\n"
|
||||||
|
"---"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"inject_context: injected {len(pack)} chars "
|
||||||
|
f"(project={project or 'none'}, prompt_chars={len(prompt)})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
_emit_context(wrapped)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
45
docs/capture-surfaces.md
Normal file
45
docs/capture-surfaces.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# AtoCore — sanctioned capture surfaces
|
||||||
|
|
||||||
|
**Scope statement**: AtoCore captures conversations from **two surfaces only**. Everything else is intentionally out of scope.
|
||||||
|
|
||||||
|
| Surface | Hooks | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| **Claude Code** (local CLI) | `Stop` (capture) + `UserPromptSubmit` (context injection) | both installed |
|
||||||
|
| **OpenClaw** (agent framework on T420) | `before_agent_start` (context injection) + `llm_output` (capture) | both installed (v0.2.0 plugin, Phase 7I) |
|
||||||
|
|
||||||
|
Both surfaces are **symmetric** — push (capture) and pull (context injection on prompt submit) — so AtoCore learns from every turn AND every turn is grounded in what AtoCore already knows.
|
||||||
|
|
||||||
|
## Why these two?
|
||||||
|
|
||||||
|
- **Stable hook APIs.** Claude Code exposes `Stop` and `UserPromptSubmit` lifecycle hooks with documented JSON contracts. OpenClaw exposes `before_agent_start` and `llm_output`. Both run locally where we control the process.
|
||||||
|
- **Passive from the user's perspective.** No paste, no manual capture command, no "remember this" prompt. You just use the tool and AtoCore absorbs everything durable.
|
||||||
|
- **Failure is graceful.** If AtoCore is down, hooks exit 0 with no output — the user's turn proceeds uninterrupted.
|
||||||
|
|
||||||
|
## Why not Claude Desktop / Claude.ai web / Claude mobile / ChatGPT / …?
|
||||||
|
|
||||||
|
- Claude Desktop has MCP but no `Stop`-equivalent hook for auto-capture; auto-capture would require system-prompt coercion ("call atocore_remember every turn"), which is fragile.
|
||||||
|
- Claude.ai web has no hook surface — would need a browser extension (real project, not shipped).
|
||||||
|
- Claude mobile app has neither hooks nor MCP — nothing to wire into.
|
||||||
|
- ChatGPT etc. — same as above.
|
||||||
|
|
||||||
|
**Anthropic API log polling is explicitly prohibited.**
|
||||||
|
|
||||||
|
If you find yourself wanting to capture from one of these, the real answer is: use Claude Code or OpenClaw for the work that matters. Don't paste chat transcripts into AtoCore — that contradicts the whole design principle of passive capture.
|
||||||
|
|
||||||
|
A `/wiki/capture` fallback form still exists (the endpoint `/interactions` is public) but it is **not promoted in the UI** and is documented as a last-resort escape hatch. If you're reaching for it, something is wrong with your workflow, not with AtoCore.
|
||||||
|
|
||||||
|
## Hook files
|
||||||
|
|
||||||
|
- `deploy/hooks/capture_stop.py` — Claude Code Stop → POSTs `/interactions`
|
||||||
|
- `deploy/hooks/inject_context.py` — Claude Code UserPromptSubmit → POSTs `/context/build`, returns pack via `hookSpecificOutput.additionalContext`
|
||||||
|
- `openclaw-plugins/atocore-capture/index.js` — OpenClaw plugin v0.2.0: capture + context injection
|
||||||
|
|
||||||
|
Both Claude Code hooks share a `_infer_project` table mapping cwd to project slug. Keep them in sync when adding a new project path.
|
||||||
|
|
||||||
|
## Kill switches
|
||||||
|
|
||||||
|
- `ATOCORE_CAPTURE_DISABLED=1` → skip Stop capture
|
||||||
|
- `ATOCORE_CONTEXT_DISABLED=1` → skip UserPromptSubmit injection
|
||||||
|
- OpenClaw plugin config `injectContext: false` → skip context injection (capture still fires)
|
||||||
|
|
||||||
|
All three are documented in the respective hook/plugin files.
|
||||||
@@ -29,7 +29,6 @@ from atocore.projects.registry import load_project_registry
|
|||||||
_TOP_NAV_LINKS = [
|
_TOP_NAV_LINKS = [
|
||||||
("🏠 Home", "/wiki"),
|
("🏠 Home", "/wiki"),
|
||||||
("📡 Activity", "/wiki/activity"),
|
("📡 Activity", "/wiki/activity"),
|
||||||
("📥 Capture", "/wiki/capture"),
|
|
||||||
("🔀 Triage", "/admin/triage"),
|
("🔀 Triage", "/admin/triage"),
|
||||||
("📊 Dashboard", "/admin/dashboard"),
|
("📊 Dashboard", "/admin/dashboard"),
|
||||||
]
|
]
|
||||||
@@ -337,16 +336,27 @@ def render_search(query: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
# Phase 7I follow-up — /wiki/capture: paste mobile/desktop chats
|
# /wiki/capture — DEPRECATED emergency paste-in form.
|
||||||
|
# Kept as an endpoint because POST /interactions is public anyway, but
|
||||||
|
# REMOVED from the topnav so it's not promoted as the capture path.
|
||||||
|
# The sanctioned surfaces are Claude Code (Stop + UserPromptSubmit
|
||||||
|
# hooks) and OpenClaw (capture plugin with 7I context injection).
|
||||||
|
# This form is explicitly a last-resort for when someone has to feed
|
||||||
|
# in an external log and can't get the normal hooks to reach it.
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def render_capture() -> str:
|
def render_capture() -> str:
|
||||||
lines = ['<h1>📥 Capture a conversation</h1>']
|
lines = ['<h1>📥 Manual capture (fallback only)</h1>']
|
||||||
lines.append(
|
lines.append(
|
||||||
'<p>Paste a chat from Claude Desktop, Claude.ai (web or mobile), '
|
'<div class="triage-warning"><strong>This is not the capture path.</strong> '
|
||||||
'or any other LLM. It goes through the same pipeline as auto-captured '
|
'The sanctioned capture surfaces are Claude Code (Stop hook auto-captures every turn) '
|
||||||
'interactions: extraction → 3-tier triage → active memory if it carries signal.</p>'
|
'and OpenClaw (plugin auto-captures + injects AtoCore context on every agent turn). '
|
||||||
|
'This form exists only as a last resort for external logs you can\'t get into the normal pipeline.</div>'
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
'<p>If you\'re reaching for this page because you had a chat somewhere AtoCore didn\'t see, '
|
||||||
|
'fix the capture surface instead — don\'t paste. The deliberate scope is Claude Code + OpenClaw.</p>'
|
||||||
)
|
)
|
||||||
lines.append('<p class="meta">Your prompt + the assistant\'s response. Project is optional — '
|
lines.append('<p class="meta">Your prompt + the assistant\'s response. Project is optional — '
|
||||||
'the extractor infers it from content.</p>')
|
'the extractor infers it from content.</p>')
|
||||||
|
|||||||
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()
|
init_engineering_schema()
|
||||||
|
|
||||||
|
|
||||||
def test_capture_page_renders(tmp_data_dir):
|
def test_capture_page_renders_as_fallback(tmp_data_dir):
|
||||||
_init_all()
|
_init_all()
|
||||||
html = render_capture()
|
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-prompt" in html
|
||||||
assert "cap-response" in html
|
assert "cap-response" in html
|
||||||
# Topnav present
|
|
||||||
assert "topnav" in html
|
|
||||||
# Source options for mobile/desktop
|
def test_capture_not_in_topnav(tmp_data_dir):
|
||||||
assert "claude-desktop" in html
|
"""The paste form should NOT appear in topnav — it's not the sanctioned path."""
|
||||||
assert "claude-mobile" in html
|
_init_all()
|
||||||
|
html = render_homepage()
|
||||||
|
assert "/wiki/capture" not in html
|
||||||
|
assert "📥 Capture" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_memory_detail_renders(tmp_data_dir):
|
def test_memory_detail_renders(tmp_data_dir):
|
||||||
@@ -125,12 +131,11 @@ def test_homepage_has_topnav_and_activity(tmp_data_dir):
|
|||||||
_init_all()
|
_init_all()
|
||||||
create_memory("knowledge", "homepage test")
|
create_memory("knowledge", "homepage test")
|
||||||
html = render_homepage()
|
html = render_homepage()
|
||||||
# Topnav with expected items
|
# Topnav with expected items (Capture removed — it's not sanctioned capture)
|
||||||
assert "🏠 Home" in html
|
assert "🏠 Home" in html
|
||||||
assert "📡 Activity" in html
|
assert "📡 Activity" in html
|
||||||
assert "📥 Capture" in html
|
|
||||||
assert "/wiki/capture" in html
|
|
||||||
assert "/wiki/activity" in html
|
assert "/wiki/activity" in html
|
||||||
|
assert "/wiki/capture" not in html
|
||||||
# Activity snippet
|
# Activity snippet
|
||||||
assert "What the brain is doing" in html
|
assert "What the brain is doing" in html
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user