gitvow 0.12.2__tar.gz → 0.12.4__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.

Potentially problematic release.


This version of gitvow might be problematic. Click here for more details.

Files changed (49) hide show
  1. {gitvow-0.12.2/src/gitvow.egg-info → gitvow-0.12.4}/PKG-INFO +1 -1
  2. {gitvow-0.12.2 → gitvow-0.12.4}/pyproject.toml +1 -1
  3. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/adapters.py +35 -3
  4. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/cli.py +11 -1
  5. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/decisions.py +2 -1
  6. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/hooks/__init__.py +3 -4
  7. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/transcript.py +48 -10
  8. {gitvow-0.12.2 → gitvow-0.12.4/src/gitvow.egg-info}/PKG-INFO +1 -1
  9. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_adapters.py +89 -0
  10. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_transcript_formats.py +59 -0
  11. {gitvow-0.12.2 → gitvow-0.12.4}/LICENSE +0 -0
  12. {gitvow-0.12.2 → gitvow-0.12.4}/README.md +0 -0
  13. {gitvow-0.12.2 → gitvow-0.12.4}/setup.cfg +0 -0
  14. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/__init__.py +0 -0
  15. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/__main__.py +0 -0
  16. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/collect.py +0 -0
  17. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/default_policy.json +0 -0
  18. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/digest.py +0 -0
  19. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/install.py +0 -0
  20. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/policy.py +0 -0
  21. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/pricing.py +0 -0
  22. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/providers.py +0 -0
  23. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/recall.py +0 -0
  24. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/redact.py +0 -0
  25. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/report.py +0 -0
  26. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/rules.py +0 -0
  27. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/selftest.py +0 -0
  28. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/snapshots.py +0 -0
  29. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow/state.py +0 -0
  30. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/SOURCES.txt +0 -0
  31. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/dependency_links.txt +0 -0
  32. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/entry_points.txt +0 -0
  33. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/requires.txt +0 -0
  34. {gitvow-0.12.2 → gitvow-0.12.4}/src/gitvow.egg-info/top_level.txt +0 -0
  35. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_collect.py +0 -0
  36. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_cost.py +0 -0
  37. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_decisions.py +0 -0
  38. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_digest.py +0 -0
  39. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_external_adapter.py +0 -0
  40. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_hooks.py +0 -0
  41. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_install_cli.py +0 -0
  42. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_packaging.py +0 -0
  43. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_policy.py +0 -0
  44. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_providers.py +0 -0
  45. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_recall.py +0 -0
  46. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_redact.py +0 -0
  47. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_report.py +0 -0
  48. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_rules.py +0 -0
  49. {gitvow-0.12.2 → gitvow-0.12.4}/tests/test_snapshots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.12.2
3
+ Version: 0.12.4
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.12.2"
7
+ version = "0.12.4"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -10,6 +10,8 @@ import shutil
10
10
  import subprocess
11
11
  from typing import Any
12
12
 
13
+ from .decisions import CARD_HEADER
14
+
13
15
  AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
14
16
 
15
17
  # agent event -> gitvow handler
@@ -216,9 +218,15 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
216
218
  )
217
219
  ]
218
220
  if event == "beforeMCPExecution":
219
- name = tool if tool.startswith("mcp__") else f"mcp__{payload.get('mcp_server_name', 'server')}__{tool}"
221
+ name = _cursor_mcp(tool, payload)
220
222
  return [(gv_event, {**base, "tool_name": name, "tool_input": inp})]
223
+ # generic preToolUse: Cursor fires this alongside the specific hook for shells and MCP calls,
224
+ # which would gate and log the same call twice; the specific hook carries the command, so it wins.
225
+ if tool in ("Shell", "shell", "run_terminal_cmd") and event == "preToolUse":
226
+ return []
221
227
  # generic preToolUse: Cursor's own tool names
228
+ if tool.startswith("MCP:") or tool.startswith("mcp__"):
229
+ return [(gv_event, {**base, "tool_name": _cursor_mcp(tool, payload), "tool_input": inp})]
222
230
  mapped = {
223
231
  "Shell": "Bash",
224
232
  "shell": "Bash",
@@ -227,23 +235,47 @@ def normalize(agent: str, event: str, payload: dict[str, Any]) -> list[tuple[str
227
235
  "write_file": "Write",
228
236
  "Edit": "Edit",
229
237
  "Write": "Write",
238
+ "Delete": "Edit", # deleting an authorization file is as consequential as editing one
230
239
  }.get(tool, tool)
231
240
  if mapped == "Bash" and "command" not in inp and payload.get("command"):
232
241
  inp["command"] = payload["command"]
242
+ if mapped in ("Write", "Edit") and "new_content" in inp:
243
+ # Cursor names the whole new file `new_content`; the gate and providers read `content`
244
+ inp.setdefault("content", inp["new_content"])
233
245
  return [(gv_event, {**base, "tool_name": mapped, "tool_input": inp})]
234
246
  return [(gv_event, {**base, "tool_name": tool, "tool_input": inp})]
235
247
 
236
248
 
249
+ def _cursor_mcp(tool: str, payload: dict[str, Any]) -> str:
250
+ """Cursor names MCP tools `MCP:<tool>` in preToolUse and passes the server separately."""
251
+ if tool.startswith("mcp__"):
252
+ return tool
253
+ name = tool[4:] if tool.startswith("MCP:") else tool
254
+ server = str(payload.get("mcp_server_name") or "server")
255
+ return f"mcp__{server}__{name}"
256
+
257
+
258
+ def _refuse(msg: str) -> bool:
259
+ """Agents with a native permission prompt must refuse, not ask, for these.
260
+
261
+ A denial is never negotiable, and the decision card is not a question about running the command:
262
+ it asks the agent to record the person's answers with `gitvow decide` and run the commit again.
263
+ Delivered as `ask`, a click on the agent's own approve button would run the commit with the
264
+ findings still open and no decision recorded anywhere.
265
+ """
266
+ return msg.startswith("BLOCKED") or msg.startswith(CARD_HEADER)
267
+
268
+
237
269
  def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
238
270
  """(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
239
271
  if agent == "cursor":
240
272
  if code == 2:
241
- perm = "deny" if msg.startswith("BLOCKED") else "ask"
273
+ perm = "deny" if _refuse(msg) else "ask"
242
274
  return 0, json.dumps({"permission": perm, "user_message": msg, "agent_message": msg}), ""
243
275
  return 0, json.dumps({"permission": "allow"}), msg
244
276
  if agent == "copilot":
245
277
  if code == 2:
246
- perm = "deny" if msg.startswith("BLOCKED") else "ask"
278
+ perm = "deny" if _refuse(msg) else "ask"
247
279
  return 0, json.dumps({"permissionDecision": perm, "permissionDecisionReason": msg}), ""
248
280
  return 0, json.dumps({"permissionDecision": "allow"}), msg
249
281
  return code, "", msg
@@ -44,7 +44,17 @@ def cmd_hook(a: argparse.Namespace) -> int:
44
44
  print(str(e), file=sys.stderr)
45
45
  return 1
46
46
  if not calls:
47
- return 0 # an event this adapter does not use
47
+ # An event this adapter does not use. Agents whose contract is a JSON permission object treat
48
+ # a silent hook as a failed hook, and gitvow installs its hooks fail-closed, so say "allow"
49
+ # explicitly rather than printing nothing.
50
+ try:
51
+ exit_code, out, err = external_respond(exe, 0, "") if exe else respond(agent, 0, "")
52
+ except AdapterError as e:
53
+ print(f"BLOCKED: agent adapter failed ({e}).", file=sys.stderr)
54
+ return 2
55
+ if out:
56
+ print(out)
57
+ return exit_code
48
58
  worst, messages = 0, []
49
59
  for gv_event, p in calls:
50
60
  handler = HANDLERS.get(gv_event)
@@ -15,6 +15,7 @@ from .state import git, load_state, save_state
15
15
 
16
16
  TRAILER_RE = re.compile(r"^Gitvow-(Accepted|Declined|Open):\s*(.+?)(?: by (\S+))?(?: scope=(\S+))?(?:: (.*))?$", re.M)
17
17
  REVISITS_RE = re.compile(r"^Gitvow-Revisits:\s*([0-9a-f]{7,40})\b", re.M)
18
+ CARD_HEADER = "DECISIONS REQUIRED"
18
19
  MAX_EVIDENCE = 8
19
20
  HISTORY_COMMITS = 3000
20
21
 
@@ -218,7 +219,7 @@ def card(
218
219
  lines = []
219
220
  if for_agent:
220
221
  lines += [
221
- f"DECISIONS REQUIRED before this commit: {len(pending)} finding{'s' if len(pending) != 1 else ''} from this session.",
222
+ f"{CARD_HEADER} before this commit: {len(pending)} finding{'s' if len(pending) != 1 else ''} from this session.",
222
223
  "Put this card to the user. Record each answer with",
223
224
  ' gitvow decide <n> accept|decline [--scope <env-or-branch>] [--reason "<phrase>"]',
224
225
  "then run the commit again.",
@@ -125,10 +125,9 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
125
125
  pending = dec.undecided(cwd)
126
126
  if pending:
127
127
  st = load_state(cwd)
128
- if rules is not None and st.get("card_user_turns") is None:
129
- st["card_user_turns"] = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)[
130
- "user_turns"
131
- ]
128
+ tp = h.get("transcript_path") or st.get("transcript_path")
129
+ if rules is not None and tp and st.get("card_user_turns") is None:
130
+ st["card_user_turns"] = summarize(tp, rules=rules)["user_turns"]
132
131
  st["card_shown_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
133
132
  save_state(cwd, st)
134
133
  proposed = dec.mark_proposals(cwd, pol)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import re
7
8
  from collections.abc import Iterable
8
9
  from typing import Any
9
10
 
@@ -40,8 +41,6 @@ def _text_of(content: Any) -> str:
40
41
 
41
42
 
42
43
  def _patch_files(text: str) -> list[str]:
43
- import re
44
-
45
44
  return re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$", text or "", re.M)
46
45
 
47
46
 
@@ -167,13 +166,50 @@ def _finish_usage(usage: dict[str, Any]) -> None:
167
166
  )
168
167
 
169
168
 
170
- def _is_person_text(content: Any) -> bool:
171
- """A user message typed by a person, not a tool_result the harness sent back."""
169
+ INJECTED_RE = re.compile(r"^\s*<[a-z_]+>") # Codex prepends <recommended_plugins>, <environment_context> and the like
170
+ PERSON_TEXT = ("text", "input_text")
171
+
172
+
173
+ def _person_text(content: Any) -> str:
172
174
  if isinstance(content, str):
173
- return bool(content.strip())
175
+ return content
174
176
  if isinstance(content, list):
175
- return any(isinstance(c, dict) and c.get("type") == "text" and str(c.get("text", "")).strip() for c in content)
176
- return False
177
+ return "\n".join(
178
+ str(c.get("text", ""))
179
+ for c in content
180
+ if isinstance(c, dict) and c.get("type") in PERSON_TEXT and str(c.get("text", "")).strip()
181
+ )
182
+ return ""
183
+
184
+
185
+ def _is_person_text(content: Any) -> bool:
186
+ """A user message typed by a person: not a tool result, and not a block the harness injected."""
187
+ text = _person_text(content).strip()
188
+ return bool(text) and not INJECTED_RE.match(text)
189
+
190
+
191
+ TOOLS_CALL_RE = re.compile(r"tools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(")
192
+
193
+
194
+ def _codex_unwrap(text: str) -> list[tuple[str, Any]]:
195
+ """Codex 0.15 wraps every tool call in a JavaScript snippet: `await tools.exec_command({...})`.
196
+
197
+ Returns (function name, decoded argument) pairs; the argument is whatever JSON follows the bracket.
198
+ """
199
+ out: list[tuple[str, Any]] = []
200
+ dec = json.JSONDecoder()
201
+ for m in TOOLS_CALL_RE.finditer(text or ""):
202
+ i = m.end()
203
+ while i < len(text) and text[i] in " \t\n\r":
204
+ i += 1
205
+ arg: Any = ""
206
+ if i < len(text) and text[i] in '{"[':
207
+ try:
208
+ arg, _ = dec.raw_decode(text, i)
209
+ except ValueError:
210
+ arg = ""
211
+ out.append((m.group(1), arg))
212
+ return out
177
213
 
178
214
 
179
215
  def _add_tool(out: dict[str, Any], tool: str, brief: str, rules: list[tuple[str, str]], max_tools: int) -> None:
@@ -256,9 +292,11 @@ def _codex_event(
256
292
  elif pt in ("function_call", "custom_tool_call", "local_shell_call"):
257
293
  name = str(p.get("name") or ("local_shell" if pt == "local_shell_call" else ""))
258
294
  arguments = p.get("arguments") if pt == "function_call" else (p.get("input") or p.get("action") or "")
259
- tool, brief, files = _codex_tool(name, arguments)
260
- _add_tool(out, tool, brief, rules, max_tools)
261
- written.update(files)
295
+ inner = _codex_unwrap(arguments) if isinstance(arguments, str) and "tools." in arguments else []
296
+ for fn, arg in inner or [(name, arguments)]:
297
+ tool, brief, files = _codex_tool(fn, arg)
298
+ _add_tool(out, tool, brief, rules, max_tools)
299
+ written.update(files)
262
300
  elif t == "turn_context":
263
301
  if p.get("model"):
264
302
  out["_codex_model"] = str(p["model"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.12.2
3
+ Version: 0.12.4
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -369,3 +369,92 @@ def test_copilot_factory_cli_and_install(repo, home, monkeypatch, capsys):
369
369
  assert (repo / ".github" / "hooks" / "gitvow.json").exists()
370
370
  uninstall_repo(str(repo), agent="copilot")
371
371
  assert not (repo / ".github" / "hooks" / "gitvow.json").exists()
372
+
373
+
374
+ def test_cursor_pretooluse_matches_the_documented_payload(repo, home):
375
+ """Cursor sends Write with new_content for file edits and MCP:<tool> for MCP calls."""
376
+ from gitvow.adapters import normalize
377
+
378
+ ev, p = normalize(
379
+ "cursor",
380
+ "preToolUse",
381
+ {
382
+ "conversation_id": "cu",
383
+ "workspace_roots": [str(repo)],
384
+ "tool_name": "Write",
385
+ "tool_input": {"file_path": "core/authz_rules.go", "new_content": 'var Public = []string{"/v1/x"}'},
386
+ },
387
+ )[0]
388
+ assert ev == "PreToolUse" and p["tool_name"] == "Write"
389
+ # the gate and the providers read `content`; Cursor's own key is preserved alongside it
390
+ assert p["tool_input"]["content"] == 'var Public = []string{"/v1/x"}'
391
+ assert p["tool_input"]["new_content"] == p["tool_input"]["content"]
392
+ ev, p = normalize(
393
+ "cursor",
394
+ "preToolUse",
395
+ {
396
+ "conversation_id": "cu",
397
+ "workspace_roots": [str(repo)],
398
+ "tool_name": "Delete",
399
+ "tool_input": {"file_path": "core/authz_rules.go"},
400
+ },
401
+ )[0]
402
+ assert p["tool_name"] == "Edit" # a deleted gate-bearing file still reaches the gate
403
+ for payload, expected in (
404
+ ({"tool_name": "MCP:drop_table", "mcp_server_name": "postgres"}, "mcp__postgres__drop_table"),
405
+ ({"tool_name": "MCP:read_rows"}, "mcp__server__read_rows"),
406
+ ({"tool_name": "mcp__already__mapped"}, "mcp__already__mapped"),
407
+ ):
408
+ ev, p = normalize("cursor", "preToolUse", {"conversation_id": "cu", "workspace_roots": [str(repo)], **payload})[
409
+ 0
410
+ ]
411
+ assert p["tool_name"] == expected
412
+
413
+
414
+ def test_the_card_is_refused_not_asked(repo, home):
415
+ """On agents with their own approve button, the card must not be clickable past."""
416
+ from gitvow.adapters import respond
417
+ from gitvow.decisions import CARD_HEADER
418
+
419
+ card = f"{CARD_HEADER} before this commit: 1 finding from this session."
420
+ for agent, key in (("cursor", "permission"), ("copilot", "permissionDecision")):
421
+ assert json.loads(respond(agent, 2, card)[1])[key] == "deny"
422
+ assert json.loads(respond(agent, 2, "BLOCKED by policy (force push).")[1])[key] == "deny"
423
+ # an immediate confirm rule is a real question for the person, so it stays their prompt
424
+ assert json.loads(respond(agent, 2, "CONFIRMATION REQUIRED (pushing to a remote).")[1])[key] == "ask"
425
+
426
+
427
+ def test_cursor_shell_is_gated_once(repo, home):
428
+ """Cursor fires preToolUse and beforeShellExecution for the same command."""
429
+ from gitvow.adapters import normalize
430
+
431
+ base = {"conversation_id": "cu", "workspace_roots": [str(repo)]}
432
+ assert (
433
+ normalize("cursor", "preToolUse", {**base, "tool_name": "Shell", "tool_input": {"command": "git commit -m x"}})
434
+ == []
435
+ )
436
+ calls = normalize("cursor", "beforeShellExecution", {**base, "command": "git commit -m x"})
437
+ assert len(calls) == 1 and calls[0][1]["tool_input"]["command"] == "git commit -m x"
438
+
439
+
440
+ def test_unused_events_still_answer_in_the_agents_form(repo, home, monkeypatch, capsys):
441
+ """gitvow's hooks are installed fail-closed; on Cursor and Copilot a silent hook is a failed hook."""
442
+ import io
443
+ import sys
444
+
445
+ from gitvow import cli
446
+
447
+ monkeypatch.chdir(repo)
448
+ payload = {
449
+ "conversation_id": "cu",
450
+ "workspace_roots": [str(repo)],
451
+ "tool_name": "Shell",
452
+ "tool_input": {"command": "git commit -m x"},
453
+ }
454
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
455
+ assert cli.main(["hook", "--agent", "cursor", "preToolUse"]) == 0
456
+ assert json.loads(capsys.readouterr().out)["permission"] == "allow"
457
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({**payload, "hook_event_name": "beforeReadFile"})))
458
+ assert cli.main(["hook", "--agent", "copilot", "sessionStart"]) == 0
459
+ out = capsys.readouterr().out
460
+ assert not out or json.loads(out).get("permissionDecision") in (None, "allow")
@@ -126,3 +126,62 @@ def test_claude_format_still_default(transcript):
126
126
  def test_redaction_applies_to_all_formats(tmp_path):
127
127
  s = summarize(_write(tmp_path, "rollout.jsonl", CODEX[:4]))
128
128
  assert "[github-token]" in s["last_assistant_text"] and "ghp_" not in s["last_assistant_text"]
129
+
130
+
131
+ # Codex 0.15 shape, captured from a real session: one `exec` custom tool whose input is a JavaScript
132
+ # snippet calling tools.exec_command / tools.apply_patch, and user text as input_text parts.
133
+ CODEX_WRAPPED = [
134
+ {"timestamp": "t", "type": "session_meta", "payload": {"id": "c2", "cwd": "/r"}},
135
+ {
136
+ "timestamp": "t",
137
+ "type": "response_item",
138
+ "payload": {
139
+ "type": "message",
140
+ "role": "user",
141
+ "content": [{"type": "input_text", "text": "<recommended_plugins>\nAirtable\n</recommended_plugins>"}],
142
+ },
143
+ },
144
+ {
145
+ "timestamp": "t",
146
+ "type": "response_item",
147
+ "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "fix add"}]},
148
+ },
149
+ {
150
+ "timestamp": "t",
151
+ "type": "response_item",
152
+ "payload": {
153
+ "type": "custom_tool_call",
154
+ "name": "exec",
155
+ "input": 'const r = await tools.exec_command({"cmd":"git status","workdir":"/r"});\nconsole.log(r);',
156
+ },
157
+ },
158
+ {
159
+ "timestamp": "t",
160
+ "type": "response_item",
161
+ "payload": {
162
+ "type": "custom_tool_call",
163
+ "name": "exec",
164
+ "input": 'await tools.apply_patch("*** Begin Patch\\n*** Update File: /r/calc.py\\n+a + b\\n*** End Patch\\n");',
165
+ },
166
+ },
167
+ {
168
+ "timestamp": "t",
169
+ "type": "response_item",
170
+ "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Fixed."}]},
171
+ },
172
+ {
173
+ "timestamp": "t",
174
+ "type": "response_item",
175
+ "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "accept it"}]},
176
+ },
177
+ ]
178
+
179
+
180
+ def test_codex_wrapped_tool_calls_and_user_turns(tmp_path):
181
+ s = summarize(_write(tmp_path, "rollout.jsonl", CODEX_WRAPPED))
182
+ assert s["format"] == "codex"
183
+ assert [t["tool"] for t in s["tool_calls"]] == ["Bash", "Edit"]
184
+ assert s["tool_calls"][0]["arg"] == "git status" and s["tool_calls"][1]["arg"] == "/r/calc.py"
185
+ assert s["files_written"] == ["/r/calc.py"]
186
+ # the injected <recommended_plugins> block is not a person speaking; the two real prompts are
187
+ assert s["user_turns"] == 2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes