sovara-claude-code 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sovara Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-claude-code
3
+ Version: 0.1.0
4
+ Summary: Record Claude Code sessions in Sovara and add relevant lessons to each prompt
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/claude-code
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-claude-code/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Dynamic: license-file
13
+
14
+ # sovara-claude-code
15
+
16
+ The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
17
+
18
+ ```sh
19
+ uv tool install sovara-claude-code
20
+ # or: pip install sovara-claude-code
21
+ ```
22
+
23
+ Then load the `sovara-recording` plugin.
24
+
25
+ ## License
26
+
27
+ MIT.
@@ -0,0 +1,14 @@
1
+ # sovara-claude-code
2
+
3
+ The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
4
+
5
+ ```sh
6
+ uv tool install sovara-claude-code
7
+ # or: pip install sovara-claude-code
8
+ ```
9
+
10
+ Then load the `sovara-recording` plugin.
11
+
12
+ ## License
13
+
14
+ MIT.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sovara-claude-code"
7
+ version = "0.1.0"
8
+ description = "Record Claude Code sessions in Sovara and add relevant lessons to each prompt"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["sovara>=0.10,<0.11"]
13
+
14
+ [project.scripts]
15
+ sovara-claude-code = "sovara_claude_code.__main__:main"
16
+
17
+ [project.urls]
18
+ Documentation = "https://docs.sovara-labs.com/integrations/claude-code"
19
+ Repository = "https://github.com/SovaraLabs/sovara-claude-code/tree/main/python"
20
+
21
+ [tool.setuptools]
22
+ package-dir = {"" = "src"}
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+ include = ["sovara_claude_code*"]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest",
31
+ "ruff==0.17.2",
32
+ "httpx",
33
+ ]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = [
41
+ "E4", "E7", "E9", "F", "I", "UP", "B",
42
+ "D100", "D101", "D102", "D103", "D104", "D106", "D107",
43
+ "D200", "D205", "D400", "D401", "D419",
44
+ "ERA001", "PGH004", "RUF100",
45
+ ]
46
+
47
+ [tool.ruff.lint.per-file-ignores]
48
+ "tests/**/*.py" = ["D1"]
49
+
50
+ [tool.ruff.lint.isort]
51
+ # Sovara's own packages sort together, as in the sovara repository.
52
+ known-first-party = ["sovara", "sovara_common", "sovara_claude_code", "tests"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ """Record Claude Code sessions in Sovara and add relevant lessons to each prompt.
2
+
3
+ Claude Code runs ``sovara-claude-code hook`` for each hook event (see the plugin in this
4
+ package's ``plugin/`` directory). ``handle(event)`` is the same entry point for tests.
5
+ """
6
+
7
+ from sovara_claude_code._hooks import handle
8
+
9
+ __all__ = ["handle"]
@@ -0,0 +1,28 @@
1
+ """Command line: ``sovara-claude-code hook`` reads one Claude Code hook event from stdin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from sovara_claude_code._hooks import handle
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ """Run a hook and print its output for Claude Code; always exits 0."""
13
+ args = sys.argv[1:] if argv is None else argv
14
+ if args != ["hook"]:
15
+ print("usage: sovara-claude-code hook", file=sys.stderr)
16
+ return 0
17
+ try:
18
+ event = json.loads(sys.stdin.read() or "{}")
19
+ except ValueError:
20
+ return 0
21
+ output = handle(event) if isinstance(event, dict) else None
22
+ if output:
23
+ print(output)
24
+ return 0
25
+
26
+
27
+ if __name__ == "__main__":
28
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ """Record a Claude Code session in Sovara from its hook events.
2
+
3
+ Hooks run as separate processes, so the session's recording is shared through a handle
4
+ (``sovara.open_recording``) kept in the session's state file. A hook never blocks Claude Code:
5
+ failures are swallowed and requests retry only briefly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import logging
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ import sovara
20
+ from sovara import (
21
+ ConversationInput,
22
+ ConversationOutput,
23
+ ErrorInfo,
24
+ ErrorOutcome,
25
+ LlmCall,
26
+ Message,
27
+ SuccessOutcome,
28
+ TextBlock,
29
+ ToolCall,
30
+ )
31
+ from sovara.integration import flush_recordings, json_value, short_retries
32
+ from sovara_claude_code import _state
33
+
34
+ logger = logging.getLogger("sovara_claude_code")
35
+
36
+ # Per-request timeout, so a hook's usual three requests fit in the 15 s Claude Code allows
37
+ # it (hooks/hooks.json).
38
+ REQUEST_TIMEOUT_SECONDS = 4.0
39
+ # How long a hook waits, before exiting, for reports the SDK sends in the background.
40
+ FLUSH_SECONDS = REQUEST_TIMEOUT_SECONDS
41
+
42
+ # Errors meaning the recording no longer exists. A write token the server no longer knows
43
+ # is a SovaraConfigError with HTTP 403.
44
+ _LOST_RECORDING = {"recording_inactive", "not_found"}
45
+
46
+
47
+ def _recording_lost(exc: Exception) -> bool:
48
+ if isinstance(exc, sovara.SovaraConfigError):
49
+ return exc.status_code == 403
50
+ return isinstance(exc, sovara.RecordingError) and exc.code in _LOST_RECORDING
51
+
52
+
53
+ def _project_name(event: dict[str, Any]) -> str:
54
+ configured = os.environ.get("SOVARA_PROJECT", "").strip()
55
+ return configured or Path(str(event.get("cwd") or os.getcwd())).name or "claude-code"
56
+
57
+
58
+ def _text_message(role: str, text: str) -> Message:
59
+ return Message(role=role, content=(TextBlock(text=text),)) # type: ignore[arg-type]
60
+
61
+
62
+ def _open(event: dict[str, Any], http_client: httpx.Client | None) -> str:
63
+ """Open a recording of the session's trace; the same session always gets the same trace."""
64
+ return sovara.open_recording(
65
+ project_name=_project_name(event),
66
+ name="Claude Code session",
67
+ trace_id=f"claude-code:{event['session_id']}",
68
+ agent_token=os.environ.get("SOVARA_AGENT_TOKEN") or None,
69
+ http_client=http_client,
70
+ )
71
+
72
+
73
+ def session_start(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
74
+ """Open the session's recording, or keep it on resume, clear and compact."""
75
+ session_id = event["session_id"]
76
+ _state.prune()
77
+ if _state.load(session_id) is not None:
78
+ return None
79
+ _state.save(session_id, {"session": _open(event, http_client), "turn": None, "prompt": None})
80
+ return None
81
+
82
+
83
+ def _start_turn(session: str, prompt: str, http_client: httpx.Client | None) -> str | None:
84
+ with sovara.resume_recording(session, http_client=http_client):
85
+ sovara.start_turn(turn_input=prompt)
86
+ return sovara.export_recording()
87
+
88
+
89
+ def _lessons(handle: str, prompt: str, http_client: httpx.Client | None) -> Any:
90
+ # Retrieved, not applied: Claude Code does not show what reaches the model.
91
+ with sovara.resume_recording(handle, http_client=http_client):
92
+ return sovara.retrieve_lessons(prompt)
93
+
94
+
95
+ def user_prompt_submit(
96
+ event: dict[str, Any], http_client: httpx.Client | None = None
97
+ ) -> str | None:
98
+ """Start a turn for the prompt and return matching lessons as extra context."""
99
+ session_id = event["session_id"]
100
+ state = _state.load(session_id)
101
+ if state is None:
102
+ # SessionStart could not open the recording; the trace key is stable, so open it now.
103
+ state = {"session": _open(event, http_client), "turn": None, "prompt": None}
104
+ _state.save(session_id, state)
105
+ prompt = str(event.get("prompt") or "")
106
+ try:
107
+ turn = _start_turn(state["session"], prompt, http_client)
108
+ except (sovara.RecordingError, sovara.SovaraConfigError) as exc:
109
+ # Only a recording the exec server lost (e.g. after a restart) is reopened; a passing
110
+ # failure must not open a second recording of the session.
111
+ if not _recording_lost(exc):
112
+ raise
113
+ state = {**state, "session": _open(event, http_client)}
114
+ turn = _start_turn(state["session"], prompt, http_client)
115
+ # Saved before retrieving lessons, so later steps join this turn even if retrieval fails.
116
+ _state.save(session_id, {**state, "turn": turn, "prompt": prompt})
117
+ try:
118
+ lessons = _lessons(turn or state["session"], prompt, http_client) if prompt else None
119
+ except sovara.RecordingError:
120
+ lessons = None
121
+ if lessons is None:
122
+ return None
123
+ return json.dumps(
124
+ {
125
+ "hookSpecificOutput": {
126
+ "hookEventName": "UserPromptSubmit",
127
+ "additionalContext": lessons.text,
128
+ }
129
+ }
130
+ )
131
+
132
+
133
+ def _record(
134
+ state: dict[str, Any], call: Any, outcome: Any, http_client: httpx.Client | None
135
+ ) -> None:
136
+ handle = state.get("turn") or state["session"]
137
+ with sovara.resume_recording(handle, http_client=http_client):
138
+ sovara.record_step(call, outcome)
139
+
140
+
141
+ def _tool_call(event: dict[str, Any]) -> ToolCall:
142
+ arguments = json_value(event.get("tool_input"))
143
+ return ToolCall(
144
+ name=str(event.get("tool_name") or "tool"),
145
+ arguments=arguments if isinstance(arguments, dict) else {"input": arguments},
146
+ call_id=str(event.get("tool_use_id") or "").strip() or None,
147
+ metadata={"source": "claude-code"},
148
+ )
149
+
150
+
151
+ def post_tool_use(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
152
+ """Record a tool call that succeeded."""
153
+ state = _state.load(event["session_id"])
154
+ if state is not None:
155
+ outcome = SuccessOutcome(output=json_value(event.get("tool_response")))
156
+ _record(state, _tool_call(event), outcome, http_client)
157
+ return None
158
+
159
+
160
+ def post_tool_use_failure(
161
+ event: dict[str, Any], http_client: httpx.Client | None = None
162
+ ) -> str | None:
163
+ """Record a tool call that failed or was interrupted."""
164
+ state = _state.load(event["session_id"])
165
+ if state is not None:
166
+ error = ErrorInfo(type="ToolError", message=str(event.get("error") or ""))
167
+ _record(state, _tool_call(event), ErrorOutcome(error=error), http_client)
168
+ return None
169
+
170
+
171
+ def stop(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
172
+ """Record Claude's reply to the turn's prompt as a model step."""
173
+ state = _state.load(event["session_id"])
174
+ reply = str(event.get("last_assistant_message") or "")
175
+ if state is None or not reply or event.get("stop_hook_active"):
176
+ return None
177
+ prompt = str(state.get("prompt") or "")
178
+ call = LlmCall(
179
+ provider="anthropic",
180
+ input=ConversationInput(messages=(_text_message("user", prompt),) if prompt else ()),
181
+ metadata={"source": "claude-code"},
182
+ )
183
+ outcome = SuccessOutcome(output=ConversationOutput(message=_text_message("assistant", reply)))
184
+ _record(state, call, outcome, http_client)
185
+ return None
186
+
187
+
188
+ def session_end(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
189
+ """Close the session's recording and forget its state."""
190
+ session_id = event["session_id"]
191
+ state = _state.load(session_id)
192
+ if state is None:
193
+ return None
194
+ try:
195
+ sovara.close_recording(state["session"], http_client=http_client)
196
+ finally:
197
+ _state.delete(session_id)
198
+ return None
199
+
200
+
201
+ HANDLERS = {
202
+ "SessionStart": session_start,
203
+ "UserPromptSubmit": user_prompt_submit,
204
+ "PostToolUse": post_tool_use,
205
+ "PostToolUseFailure": post_tool_use_failure,
206
+ "Stop": stop,
207
+ "SessionEnd": session_end,
208
+ }
209
+
210
+
211
+ def handle(event: dict[str, Any], http_client: httpx.Client | None = None) -> str | None:
212
+ """Run the handler for one hook event; return text for Claude Code, or None.
213
+
214
+ Never raises: a failure is logged by type only and the session continues unrecorded.
215
+ """
216
+ handler = HANDLERS.get(str(event.get("hook_event_name")))
217
+ if handler is None or not event.get("session_id"):
218
+ return None
219
+ try:
220
+ with short_retries(), contextlib.ExitStack() as stack:
221
+ if http_client is None:
222
+ http_client = stack.enter_context(httpx.Client(timeout=REQUEST_TIMEOUT_SECONDS))
223
+ output = handler(event, http_client)
224
+ # Reports queued in the background use http_client, which closes on return.
225
+ flush_recordings(FLUSH_SECONDS)
226
+ return output
227
+ except Exception as exc:
228
+ logger.warning(
229
+ "Sovara Claude Code hook failed event=%s error_type=%s",
230
+ event.get("hook_event_name"),
231
+ type(exc).__name__,
232
+ )
233
+ return None
@@ -0,0 +1,73 @@
1
+ """Per-session state shared by the hook processes of one Claude Code session.
2
+
3
+ Each session has one JSON file holding its recording handles, which carry a write token: the
4
+ file is private (0600), written atomically, and never logged. The format is shared with the
5
+ TypeScript integration (``@sovara/claude-code``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import tempfile
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ # A session whose SessionEnd never ran (a crash) is forgotten after this long.
19
+ STALE_AFTER_SECONDS = 7 * 24 * 3600
20
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
21
+
22
+
23
+ def state_dir() -> Path:
24
+ """Return the state directory: the plugin's data directory, else ``~/.sovara/claude-code``."""
25
+ plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "").strip()
26
+ if plugin_data:
27
+ return Path(plugin_data)
28
+ return Path.home() / ".sovara" / "claude-code"
29
+
30
+
31
+ def _path(session_id: str) -> Path:
32
+ if not _SAFE_ID.match(session_id):
33
+ raise ValueError("Unsupported Claude Code session ID.")
34
+ return state_dir() / f"{session_id}.json"
35
+
36
+
37
+ def load(session_id: str) -> dict[str, Any] | None:
38
+ """Return the session's state, or None when it has none."""
39
+ try:
40
+ return json.loads(_path(session_id).read_text(encoding="utf-8"))
41
+ except FileNotFoundError:
42
+ return None
43
+
44
+
45
+ def save(session_id: str, state: dict[str, Any]) -> None:
46
+ """Replace the session's state atomically, readable only by the current user."""
47
+ path = _path(session_id)
48
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
50
+ try:
51
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
52
+ json.dump(state, file)
53
+ os.chmod(tmp, 0o600)
54
+ os.replace(tmp, path)
55
+ except BaseException:
56
+ Path(tmp).unlink(missing_ok=True)
57
+ raise
58
+
59
+
60
+ def delete(session_id: str) -> None:
61
+ """Forget the session's state."""
62
+ _path(session_id).unlink(missing_ok=True)
63
+
64
+
65
+ def prune() -> None:
66
+ """Delete state files of sessions that ended without SessionEnd."""
67
+ cutoff = time.time() - STALE_AFTER_SECONDS
68
+ for path in state_dir().glob("*.json"):
69
+ try:
70
+ if path.stat().st_mtime < cutoff:
71
+ path.unlink()
72
+ except OSError:
73
+ continue
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-claude-code
3
+ Version: 0.1.0
4
+ Summary: Record Claude Code sessions in Sovara and add relevant lessons to each prompt
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/claude-code
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-claude-code/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Dynamic: license-file
13
+
14
+ # sovara-claude-code
15
+
16
+ The Python hook program for [Sovara for Claude Code](https://github.com/SovaraLabs/sovara-claude-code#readme).
17
+
18
+ ```sh
19
+ uv tool install sovara-claude-code
20
+ # or: pip install sovara-claude-code
21
+ ```
22
+
23
+ Then load the `sovara-recording` plugin.
24
+
25
+ ## License
26
+
27
+ MIT.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sovara_claude_code/__init__.py
5
+ src/sovara_claude_code/__main__.py
6
+ src/sovara_claude_code/_hooks.py
7
+ src/sovara_claude_code/_state.py
8
+ src/sovara_claude_code.egg-info/PKG-INFO
9
+ src/sovara_claude_code.egg-info/SOURCES.txt
10
+ src/sovara_claude_code.egg-info/dependency_links.txt
11
+ src/sovara_claude_code.egg-info/entry_points.txt
12
+ src/sovara_claude_code.egg-info/requires.txt
13
+ src/sovara_claude_code.egg-info/top_level.txt
14
+ tests/test_sovara_claude_code.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sovara-claude-code = sovara_claude_code.__main__:main
@@ -0,0 +1 @@
1
+ sovara<0.11,>=0.10
@@ -0,0 +1 @@
1
+ sovara_claude_code
@@ -0,0 +1,223 @@
1
+ """A Claude Code session recorded through its hooks, against a fake exec server."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from sovara_claude_code import _state, handle
9
+ from sovara_claude_code._hooks import REQUEST_TIMEOUT_SECONDS
10
+
11
+ TOKEN = "write-token-secret"
12
+ HEALTH = {
13
+ "status": "ok",
14
+ "service": "sovara-exec-server",
15
+ "compatibility": {"component": "exec-server", "contracts": {"runner-api": [6]}},
16
+ }
17
+
18
+
19
+ class FakeExecServer:
20
+ """Answer the runner-api 6 routes the hooks use and keep the requests."""
21
+
22
+ def __init__(self, lessons: str | None = "Run the tests first.") -> None:
23
+ self.lessons = lessons
24
+ self.lost_recordings = 0
25
+ self.failing_turns = 0
26
+ self.failing_status = 500
27
+ self.failing_lessons = False
28
+ self.failing_opens = 0
29
+ self.requests: list[tuple[str, dict]] = []
30
+
31
+ def __call__(self, request: httpx.Request) -> httpx.Response:
32
+ path = request.url.path
33
+ if path == "/exec/health":
34
+ return httpx.Response(200, json=HEALTH)
35
+ body = json.loads(request.content or b"{}")
36
+ self.requests.append((path, body))
37
+ if path == "/exec/traces" and self.failing_opens:
38
+ self.failing_opens -= 1
39
+ return httpx.Response(500, json={"error": "internal_error", "message": "x"})
40
+ if path == "/exec/traces":
41
+ return httpx.Response(
42
+ 200,
43
+ json={
44
+ "trace_id": "trace-1",
45
+ "recording_id": "rec-1",
46
+ "project_id": "project-1",
47
+ "appended": False,
48
+ "trace_write_token": TOKEN,
49
+ },
50
+ )
51
+ if path.endswith("/turns"):
52
+ if self.failing_turns:
53
+ self.failing_turns -= 1
54
+ return httpx.Response(self.failing_status, json={"error": "x", "message": "x"})
55
+ if self.lost_recordings:
56
+ self.lost_recordings -= 1
57
+ return httpx.Response(
58
+ 409, json={"error": "recording_inactive", "message": "Not active."}
59
+ )
60
+ return httpx.Response(200, json={"turn_id": "turn-1", "recording_id": "rec-1"})
61
+ if path.endswith("/lesson-retrievals") and self.failing_lessons:
62
+ return httpx.Response(500, json={"error": "internal_error", "message": "x"})
63
+ if path.endswith("/lesson-retrievals"):
64
+ context = (
65
+ {"text": self.lessons, "retrieval_id": "retrieval-1"} if self.lessons else None
66
+ )
67
+ return httpx.Response(200, json={"ok": True, "lesson_context": context})
68
+ if path.endswith("/steps/record"):
69
+ return httpx.Response(200, json={"ok": True, "step_uuid": body["step_uuid"]})
70
+ return httpx.Response(200, json={"ok": True})
71
+
72
+ def paths(self) -> list[str]:
73
+ return [path.removeprefix("/exec/traces/trace-1") or path for path, _ in self.requests]
74
+
75
+
76
+ @pytest.fixture
77
+ def server(tmp_path, monkeypatch):
78
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
79
+ monkeypatch.setenv("SOVARA_EXEC_SERVER_URL", "http://exec.test")
80
+ fake = FakeExecServer()
81
+ with httpx.Client(transport=httpx.MockTransport(fake)) as client:
82
+ yield fake, client
83
+
84
+
85
+ def _event(name: str, **fields) -> dict:
86
+ return {"hook_event_name": name, "session_id": "s1", "cwd": "/work/my-repo", **fields}
87
+
88
+
89
+ def test_a_session_is_recorded_with_lessons_tools_and_the_reply(server, tmp_path, caplog):
90
+ fake, client = server
91
+ assert handle(_event("SessionStart", source="startup"), client) is None
92
+ output = handle(_event("UserPromptSubmit", prompt="Fix the build"), client)
93
+ handle(
94
+ _event(
95
+ "PostToolUse",
96
+ tool_name="Bash",
97
+ tool_input={"command": "make"},
98
+ tool_response={"stdout": "ok"},
99
+ tool_use_id="t1",
100
+ ),
101
+ client,
102
+ )
103
+ handle(
104
+ _event(
105
+ "PostToolUseFailure",
106
+ tool_name="Bash",
107
+ tool_input={"command": "x"},
108
+ error="Exit code 1",
109
+ tool_use_id="t2",
110
+ ),
111
+ client,
112
+ )
113
+ handle(_event("Stop", last_assistant_message="Fixed it."), client)
114
+ handle(_event("SessionEnd", reason="other"), client)
115
+
116
+ assert json.loads(output or "")["hookSpecificOutput"]["additionalContext"] == (
117
+ "Run the tests first."
118
+ )
119
+ assert fake.paths() == [
120
+ "/exec/traces",
121
+ "/turns",
122
+ "/lesson-retrievals",
123
+ "/steps/record",
124
+ "/steps/record",
125
+ "/steps/record",
126
+ "/complete",
127
+ ]
128
+ opened = fake.requests[0][1]
129
+ assert (opened["project_name"], opened["trace_id"]) == ("my-repo", "claude-code:s1")
130
+ tool, failed, reply = (body for path, body in fake.requests if path.endswith("/record"))
131
+ assert tool["call"]["value"]["name"] == "Bash" and tool["turn_id"] == "turn-1"
132
+ assert failed["outcome"]["value"]["status"] == "error"
133
+ assert reply["call"]["value"]["kind"] == "llm"
134
+ assert reply["outcome"]["value"]["output"]["message"]["content"][0]["text"] == "Fixed it."
135
+ assert list(tmp_path.glob("*.json")) == []
136
+ assert TOKEN not in caplog.text
137
+
138
+
139
+ def test_a_resumed_session_keeps_its_recording(server):
140
+ fake, client = server
141
+ handle(_event("SessionStart", source="startup"), client)
142
+ handle(_event("SessionStart", source="resume"), client)
143
+ assert fake.paths() == ["/exec/traces"]
144
+
145
+
146
+ def test_state_is_private_and_failures_never_reach_claude_code(server, tmp_path, monkeypatch):
147
+ fake, client = server
148
+ handle(_event("SessionStart", source="startup"), client)
149
+ assert oct((tmp_path / "s1.json").stat().st_mode & 0o777) == "0o600"
150
+
151
+ def down(request):
152
+ raise httpx.ConnectError("down", request=request)
153
+
154
+ with httpx.Client(transport=httpx.MockTransport(down)) as broken:
155
+ assert handle(_event("UserPromptSubmit", prompt="hi"), broken) is None
156
+ assert handle({"hook_event_name": "Unknown"}, client) is None
157
+ with pytest.raises(ValueError):
158
+ _state.load("../escape")
159
+
160
+
161
+ def test_a_recording_lost_by_the_server_is_reopened_at_the_next_prompt(server):
162
+ fake, client = server
163
+ handle(_event("SessionStart", source="startup"), client)
164
+ fake.lost_recordings = 1 # e.g. the exec server restarted
165
+
166
+ output = handle(_event("UserPromptSubmit", prompt="Fix the build"), client)
167
+
168
+ assert output is not None
169
+ assert fake.paths() == [
170
+ "/exec/traces",
171
+ "/turns",
172
+ "/exec/traces",
173
+ "/turns",
174
+ "/lesson-retrievals",
175
+ ]
176
+ assert fake.requests[2][1]["trace_id"] == "claude-code:s1"
177
+
178
+
179
+ @pytest.mark.parametrize("status", [500, 422])
180
+ def test_a_passing_failure_does_not_open_a_second_recording(server, status):
181
+ fake, client = server
182
+ handle(_event("SessionStart", source="startup"), client)
183
+ fake.failing_turns, fake.failing_status = 1, status
184
+
185
+ assert handle(_event("UserPromptSubmit", prompt="hi"), client) is None
186
+ assert fake.paths() == ["/exec/traces", "/turns"]
187
+
188
+
189
+ def test_a_failed_lesson_retrieval_still_starts_the_turn(server):
190
+ fake, client = server
191
+ handle(_event("SessionStart", source="startup"), client)
192
+ fake.failing_lessons = True
193
+
194
+ assert handle(_event("UserPromptSubmit", prompt="second"), client) is None
195
+ handle(_event("Stop", last_assistant_message="Done."), client)
196
+
197
+ reply = fake.requests[-1][1]
198
+ assert reply["turn_id"] == "turn-1"
199
+ assert reply["call"]["value"]["input"]["messages"][0]["content"][0]["text"] == "second"
200
+
201
+
202
+ def test_a_session_that_could_not_start_is_recorded_from_the_next_prompt(server):
203
+ fake, client = server
204
+ fake.failing_opens = 1
205
+ handle(_event("SessionStart", source="startup"), client)
206
+
207
+ assert handle(_event("UserPromptSubmit", prompt="hi"), client) is not None
208
+ assert fake.paths() == ["/exec/traces", "/exec/traces", "/turns", "/lesson-retrievals"]
209
+
210
+
211
+ def test_hooks_without_a_client_time_out_within_the_hook_limit(server, monkeypatch):
212
+ fake, _ = server
213
+ timeouts = []
214
+ real_client = httpx.Client
215
+
216
+ def client(**kwargs):
217
+ timeouts.append(kwargs["timeout"])
218
+ return real_client(transport=httpx.MockTransport(fake), **kwargs)
219
+
220
+ monkeypatch.setattr("sovara_claude_code._hooks.httpx.Client", client)
221
+ handle(_event("SessionStart", source="startup"))
222
+ assert handle(_event("UserPromptSubmit", prompt="hi")) is not None
223
+ assert timeouts == [REQUEST_TIMEOUT_SECONDS] * 2