From b026a6343421bef98e9b8d5fc528694168efd470 Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Thu, 11 Jun 2026 06:53:51 +0000 Subject: [PATCH] Record empty stop hook payloads Co-authored-by: Codex --- docs/setup.md | 10 +++++--- src/agent_trace/__init__.py | 2 +- src/agent_trace/cli.py | 12 +++++++++- src/agent_trace/hooks.py | 47 ++++++++++++++++++++++++++++++------- tests/test_copilot_hooks.py | 20 ++++++++++++++++ tests/test_cursor_hooks.py | 5 ++++ tests/test_hooks.py | 9 +++++-- 7 files changed, 90 insertions(+), 15 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index ad5c46b..dc39136 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -131,12 +131,13 @@ Set `CURSOR_CONFIG_DIR` to write the file somewhere else. The generated config r "afterShellExecution": [{ "type": "command", "command": "agent-strace hook --provider cursor after-shell-execution" }], "afterFileEdit": [{ "type": "command", "command": "agent-strace hook --provider cursor after-file-edit" }], "afterAgentResponse": [{ "type": "command", "command": "agent-strace hook --provider cursor after-agent-response" }], + "stop": [{ "type": "command", "command": "agent-strace hook --provider cursor stop" }], "sessionEnd": [{ "type": "command", "command": "agent-strace hook --provider cursor session-end" }] } } ``` -Cursor hook coverage depends on the events Cursor emits. Native hooks capture prompts, shell commands, file edits, and agent responses when available. MCP server tool calls are still captured most reliably through the MCP proxy configuration below. +Cursor hook coverage depends on the events Cursor emits. Native hooks capture prompts, shell commands, file edits, stop markers, and agent responses when available. MCP server tool calls are still captured most reliably through the MCP proxy configuration below. ### GitHub Copilot CLI hooks @@ -159,12 +160,15 @@ Set `COPILOT_HOME` to install into a different Copilot config directory. The gen "PreToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot pre-tool" }] }], "PostToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool" }] }], "PostToolUseFailure": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool-failure" }] }], - "AgentStop": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot stop" }] }] + "Stop": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot stop" }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot session-end" }] }] } } ``` -Copilot sends hook payloads on stdin. agent-strace records session starts, user prompts, and hook-visible tool calls/results. `AgentStop` is registered so sessions can receive stop events when Copilot emits useful payload data; assistant text capture depends on the fields Copilot includes. +Copilot sends hook payloads on stdin. agent-strace records session starts, user prompts, hook-visible tool calls/results, stop markers, and session ends. Assistant text capture depends on the fields Copilot includes; stop hooks often provide `transcript_path` and `stop_reason` rather than response text. + +Stop and session-end coverage is provider-defined. agent-strace records these hooks when the agent CLI emits them, including empty stop payloads, but it cannot force an agent to emit a stop hook for every UI action such as deleting a conversation or interrupting a run. ### Import existing sessions diff --git a/src/agent_trace/__init__.py b/src/agent_trace/__init__.py index 37aefb5..8bf457d 100644 --- a/src/agent_trace/__init__.py +++ b/src/agent_trace/__init__.py @@ -1,3 +1,3 @@ """agent-trace: strace for AI agents.""" -__version__ = "0.80.0" +__version__ = "0.80.1" diff --git a/src/agent_trace/cli.py b/src/agent_trace/cli.py index 7635c47..b58432b 100644 --- a/src/agent_trace/cli.py +++ b/src/agent_trace/cli.py @@ -657,12 +657,18 @@ def _copilot_hooks_config(args: argparse.Namespace) -> dict: "command": f"{cmd_prefix} post-tool-failure", }], }], - "AgentStop": [{ + "Stop": [{ "hooks": [{ "type": "command", "command": f"{cmd_prefix} stop", }], }], + "SessionEnd": [{ + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} session-end", + }], + }], } } @@ -758,6 +764,10 @@ def _cursor_hooks_config(args: argparse.Namespace) -> dict: "type": "command", "command": f"{cmd_prefix} after-agent-response", }], + "stop": [{ + "type": "command", + "command": f"{cmd_prefix} stop", + }], "sessionEnd": [{ "type": "command", "command": f"{cmd_prefix} session-end", diff --git a/src/agent_trace/hooks.py b/src/agent_trace/hooks.py index b6ab2eb..45fb2f4 100644 --- a/src/agent_trace/hooks.py +++ b/src/agent_trace/hooks.py @@ -215,6 +215,8 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> dict: data.setdefault("session_id", data.get("sessionId") or "") data.setdefault("turn_id", data.get("turnId") or "") data.setdefault("tool_use_id", data.get("toolUseId") or "") + data.setdefault("stop_reason", data.get("stopReason") or "") + data.setdefault("transcript_path", data.get("transcriptPath") or "") if event in {"pre-tool", "post-tool", "post-tool-failure"}: tool = data.get("tool") @@ -246,12 +248,18 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> dict: if event == "user-prompt": input_value = data.get("input", {}) prompt = input_value.get("prompt", "") if isinstance(input_value, dict) else input_value - data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or data.get("initialPrompt") or prompt or "") + data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or data.get("initialPrompt") or data.get("initial_prompt") or prompt or "") if event == "stop": data.setdefault("last_assistant_message", data.get("prompt_response", "")) if event == "stop" and data.get("last_assistant_message") is None: - data["last_assistant_message"] = data.get("assistant_message") or data.get("message") or "" + data["last_assistant_message"] = ( + data.get("assistant_message") + or data.get("message") + or data.get("response") + or data.get("text") + or "" + ) return data @@ -315,12 +323,17 @@ def handle_session_end(input_data: dict, provider: str = "claude") -> None: meta.ended_at = time.time() meta.total_duration_ms = (meta.ended_at - meta.started_at) * 1000 + event_data = {"duration_ms": meta.total_duration_ms} + for key in ("reason", "stop_reason", "cwd", "transcript_path", "hook_event_name"): + if input_data.get(key) not in (None, ""): + event_data[key] = input_data.get(key) + _write_event(store, session_id, TraceEvent( event_type=EventType.SESSION_END, session_id=session_id, - data={"duration_ms": meta.total_duration_ms}, + data=event_data, ), ) store.update_meta(meta) @@ -415,11 +428,20 @@ def handle_stop(input_data: dict, provider: str = "claude") -> None: redact = _should_redact() text = input_data.get("last_assistant_message", "") - if not text: - return - - event_data = {"text": text} - for key in ("turn_id", "permission_mode"): + event_data = { + "text": text, + "hook_event": "stop", + "provider": provider, + } + for key in ( + "turn_id", + "permission_mode", + "stop_reason", + "reason", + "transcript_path", + "cwd", + "hook_event_name", + ): if input_data.get(key) not in (None, ""): event_data[key] = input_data.get(key) if redact: @@ -580,13 +602,22 @@ def hook_main(args: list[str]) -> None: "after-shell-execution": "post-tool", "after-file-edit": "file-write", "after-agent-response": "stop", + "afterAgentResponse": "stop", + "sessionStart": "session-start", + "sessionEnd": "session-end", "SessionStart": "session-start", "SessionEnd": "session-end", + "userPromptSubmitted": "user-prompt", "UserPromptSubmit": "user-prompt", + "preToolUse": "pre-tool", "PreToolUse": "pre-tool", + "postToolUse": "post-tool", "PostToolUse": "post-tool", + "postToolUseFailure": "post-tool-failure", "PostToolUseFailure": "post-tool-failure", + "agentStop": "stop", "AgentStop": "stop", + "Stop": "stop", "agent-stop": "stop", } event = aliases.get(rest[0], rest[0]) diff --git a/tests/test_copilot_hooks.py b/tests/test_copilot_hooks.py index 859ab2d..7d97259 100644 --- a/tests/test_copilot_hooks.py +++ b/tests/test_copilot_hooks.py @@ -65,12 +65,24 @@ def test_copilot_hook_main_normalizes_camel_case_payloads(self): with patch.object(sys, "stdin", io.StringIO(result_payload)): hook_main(["--provider", "copilot", "post-tool"]) + stop_payload = json.dumps({ + "sessionId": "copilotsession123456", + "stopReason": "end_turn", + "transcriptPath": "/tmp/copilot-transcript.jsonl", + }) + with patch.object(sys, "stdin", io.StringIO(stop_payload)): + hook_main(["--provider", "copilot", "agentStop"]) + store = TraceStore(self.tmpdir) meta = store.load_meta(session_id) events = store.load_events(session_id) prompts = [event for event in events if event.event_type == EventType.USER_PROMPT] calls = [event for event in events if event.event_type == EventType.TOOL_CALL] results = [event for event in events if event.event_type == EventType.TOOL_RESULT] + stops = [ + event for event in events + if event.event_type == EventType.ASSISTANT_RESPONSE and event.data.get("hook_event") == "stop" + ] self.assertEqual(meta.agent_name, "github-copilot") self.assertEqual(events[0].data["provider"], "copilot") @@ -78,6 +90,8 @@ def test_copilot_hook_main_normalizes_camel_case_payloads(self): self.assertEqual(calls[0].data["tool_name"], "terminal") self.assertEqual(calls[0].data["arguments"]["command"], "pytest") self.assertEqual(results[0].parent_id, calls[0].event_id) + self.assertEqual(stops[0].data["stop_reason"], "end_turn") + self.assertEqual(stops[0].data["transcript_path"], "/tmp/copilot-transcript.jsonl") class TestCopilotSetup(unittest.TestCase): @@ -109,10 +123,16 @@ def test_setup_cli_copilot_writes_user_hooks_file(self): self.assertEqual(hooks["version"], 1) self.assertIn("SessionStart", hooks["hooks"]) self.assertIn("PostToolUseFailure", hooks["hooks"]) + self.assertIn("Stop", hooks["hooks"]) + self.assertIn("SessionEnd", hooks["hooks"]) self.assertEqual( hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], "agent-strace hook --provider copilot user-prompt", ) + self.assertEqual( + hooks["hooks"]["Stop"][0]["hooks"][0]["command"], + "agent-strace hook --provider copilot stop", + ) self.assertIn("GitHub Copilot hooks config", err.getvalue()) diff --git a/tests/test_cursor_hooks.py b/tests/test_cursor_hooks.py index a2a9c7f..8123e00 100644 --- a/tests/test_cursor_hooks.py +++ b/tests/test_cursor_hooks.py @@ -177,10 +177,15 @@ def test_setup_cli_cursor_writes_hooks_json(self): self.assertIn("beforeSubmitPrompt", hooks["hooks"]) self.assertIn("beforeShellExecution", hooks["hooks"]) self.assertIn("afterFileEdit", hooks["hooks"]) + self.assertIn("stop", hooks["hooks"]) self.assertEqual( hooks["hooks"]["afterFileEdit"][0]["command"], "agent-strace hook --provider cursor after-file-edit", ) + self.assertEqual( + hooks["hooks"]["stop"][0]["command"], + "agent-strace hook --provider cursor stop", + ) self.assertEqual(printed_hooks, hooks) self.assertIn("Cursor hooks config", err.getvalue()) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 87e9ab3..8c60b32 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -278,15 +278,20 @@ def test_stop_skips_when_stop_hook_active(self): responses = [e for e in events if e.event_type == EventType.ASSISTANT_RESPONSE] self.assertEqual(len(responses), 0) - def test_stop_skips_empty_message(self): + def test_stop_records_marker_without_message(self): handle_stop({ "last_assistant_message": "", + "stop_reason": "end_turn", + "transcript_path": "/tmp/transcript.jsonl", }) store = TraceStore(self.tmpdir) events = store.load_events(self.session_id) responses = [e for e in events if e.event_type == EventType.ASSISTANT_RESPONSE] - self.assertEqual(len(responses), 0) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0].data["hook_event"], "stop") + self.assertEqual(responses[0].data["stop_reason"], "end_turn") + self.assertEqual(responses[0].data["transcript_path"], "/tmp/transcript.jsonl") def test_stop_without_session_is_noop(self): _clear_active_session()