Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/agent_trace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""agent-trace: strace for AI agents."""

__version__ = "0.80.0"
__version__ = "0.80.1"
12 changes: 11 additions & 1 deletion src/agent_trace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}],
}],
}
}

Expand Down Expand Up @@ -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",
Expand Down
47 changes: 39 additions & 8 deletions src/agent_trace/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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])
Expand Down
20 changes: 20 additions & 0 deletions tests/test_copilot_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,33 @@ 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")
self.assertEqual(prompts[0].data["prompt"], "Run the checks")
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):
Expand Down Expand Up @@ -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())


Expand Down
5 changes: 5 additions & 0 deletions tests/test_cursor_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
9 changes: 7 additions & 2 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading