agentshim 0.3.0__tar.gz → 0.3.1__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.
Files changed (65) hide show
  1. {agentshim-0.3.0 → agentshim-0.3.1}/PKG-INFO +1 -1
  2. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/agent.py +7 -0
  3. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/events.py +11 -2
  4. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/cli_agent.py +5 -0
  5. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/agent.py +72 -13
  6. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/events.py +37 -15
  7. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/events.py +4 -0
  8. {agentshim-0.3.0 → agentshim-0.3.1}/pyproject.toml +1 -1
  9. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_claude.py +66 -0
  10. agentshim-0.3.1/tests/unit/test_agent_cli_codex.py +243 -0
  11. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_event_parsing.py +62 -4
  12. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_cli_agent_usage.py +37 -0
  13. {agentshim-0.3.0 → agentshim-0.3.1}/uv.lock +1 -1
  14. agentshim-0.3.0/tests/unit/test_agent_cli_codex.py +0 -69
  15. {agentshim-0.3.0 → agentshim-0.3.1}/.github/workflows/ci.yml +0 -0
  16. {agentshim-0.3.0 → agentshim-0.3.1}/.github/workflows/publish.yml +0 -0
  17. {agentshim-0.3.0 → agentshim-0.3.1}/.gitignore +0 -0
  18. {agentshim-0.3.0 → agentshim-0.3.1}/README.md +0 -0
  19. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/__init__.py +0 -0
  20. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/base.py +0 -0
  21. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/__init__.py +0 -0
  22. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/hooks/__init__.py +0 -0
  23. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/hooks/confine_reads.py +0 -0
  24. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude_events.py +0 -0
  25. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/__init__.py +0 -0
  26. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex_events.py +0 -0
  27. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/__init__.py +0 -0
  28. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/agent.py +0 -0
  29. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/events.py +0 -0
  30. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot_events.py +0 -0
  31. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/__init__.py +0 -0
  32. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/agent.py +0 -0
  33. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/events.py +0 -0
  34. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini_events.py +0 -0
  35. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/llm_client.py +0 -0
  36. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/mcp_config.py +0 -0
  37. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/__init__.py +0 -0
  38. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/agent.py +0 -0
  39. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/events.py +0 -0
  40. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode_events.py +0 -0
  41. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/py.typed +0 -0
  42. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/sandbox.py +0 -0
  43. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/subagent.py +0 -0
  44. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/trajectory.py +0 -0
  45. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/usage.py +0 -0
  46. {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/utils.py +0 -0
  47. {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/session_turn_1.jsonl +0 -0
  48. {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/session_turn_2_resumed.jsonl +0 -0
  49. {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/streaming_dedup.jsonl +0 -0
  50. {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/tool_and_usage.jsonl +0 -0
  51. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -0
  52. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_check_cli.py +0 -0
  53. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_cli_prompt_passing.py +0 -0
  54. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/conftest.py +0 -0
  55. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_claude_stream.py +0 -0
  56. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_fixture.py +0 -0
  57. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_stream.py +0 -0
  58. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_copilot.py +0 -0
  59. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_copilot_fixtures.py +0 -0
  60. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_mcp_unsupported.py +0 -0
  61. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_resume.py +0 -0
  62. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_sandbox.py +0 -0
  63. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_facade.py +0 -0
  64. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_recorder_default.py +0 -0
  65. {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_mcp_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentshim
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: litellm>=1.0.0
@@ -53,6 +53,10 @@ class ClaudeGenerationSession(CLIGenerationSession):
53
53
  def _handle_event(self, event: ClaudeEvent):
54
54
  """Handle a single parsed Claude event."""
55
55
  if isinstance(event, MultiEvent):
56
+ if event.usage and self.event_handler is not None:
57
+ on_usage = getattr(self.event_handler, "on_usage", None)
58
+ if on_usage is not None:
59
+ on_usage(event.usage)
56
60
  for sub_event in event.events:
57
61
  self._handle_event(sub_event)
58
62
  return
@@ -105,6 +109,9 @@ class ClaudeGenerationSession(CLIGenerationSession):
105
109
 
106
110
  elif isinstance(event, ResultEvent):
107
111
  self.final_result = event.result
112
+ self.final_usage = event.usage
113
+ self.total_cost_usd = event.total_cost_usd
114
+ self.duration_ms = event.duration_ms
108
115
  # Anthropic reports cache_creation + cache_read as disjoint
109
116
  # from input_tokens; fold them into input_tokens to match the
110
117
  # crucible invariant (cached ⊆ input).
@@ -36,7 +36,8 @@ class ClaudeEvent(ABC):
36
36
  parameters=block.get("input"),
37
37
  )
38
38
  )
39
- return MultiEvent(events) if events else None
39
+ usage = message.get("usage")
40
+ return MultiEvent(events, usage=usage) if events else None
40
41
  if event_type == "user":
41
42
  message = data.get("message", {})
42
43
  content_blocks = message.get("content", [])
@@ -53,6 +54,7 @@ class ClaudeEvent(ABC):
53
54
  num_turns=data.get("num_turns"),
54
55
  usage=data.get("usage"),
55
56
  total_cost_usd=data.get("total_cost_usd"),
57
+ duration_ms=data.get("duration_ms"),
56
58
  )
57
59
 
58
60
  return None
@@ -61,8 +63,13 @@ class ClaudeEvent(ABC):
61
63
  class MultiEvent(ClaudeEvent):
62
64
  """Container for multiple events from a single message."""
63
65
 
64
- def __init__(self, events: list[ClaudeEvent]):
66
+ def __init__(
67
+ self,
68
+ events: list[ClaudeEvent],
69
+ usage: dict[str, Any] | None = None,
70
+ ):
65
71
  self.events = events
72
+ self.usage = usage
66
73
 
67
74
  def render(self, log_prefix: str) -> str | None:
68
75
  # MultiEvent doesn't render itself; events are handled individually
@@ -138,11 +145,13 @@ class ResultEvent(ClaudeEvent):
138
145
  num_turns: int | None = None,
139
146
  usage: dict[str, Any] | None = None,
140
147
  total_cost_usd: float | None = None,
148
+ duration_ms: int | None = None,
141
149
  ):
142
150
  self.result = result
143
151
  self.num_turns = num_turns
144
152
  self.usage = usage
145
153
  self.total_cost_usd = total_cost_usd
154
+ self.duration_ms = duration_ms
146
155
 
147
156
  def render(self, log_prefix: str) -> str | None:
148
157
  # Result events are silent (result is captured separately)
@@ -56,6 +56,11 @@ class CLIGenerationSession:
56
56
  # Providers populate this during event handling; stays at the
57
57
  # empty default if the session crashes before any terminal event.
58
58
  self.usage: ProviderUsage = ProviderUsage()
59
+ # Final provider-reported accounting snapshot, when the CLI exposes
60
+ # one on a terminal event.
61
+ self.final_usage: dict[str, Any] | None = None
62
+ self.total_cost_usd: float | None = None
63
+ self.duration_ms: int | None = None
59
64
  # Provider session id captured from the event stream (set by subclasses
60
65
  # that parse JSON events). ``None`` if the underlying CLI did not emit
61
66
  # an id during this run.
@@ -15,6 +15,7 @@ from ..usage import ProviderUsage, TokenUsage
15
15
  from .events import (
16
16
  CodexEvent,
17
17
  ErrorEvent,
18
+ LifecycleEvent,
18
19
  TextEvent,
19
20
  ThreadStartedEvent,
20
21
  ToolResultEvent,
@@ -38,21 +39,28 @@ class CodexGenerationSession(CLIGenerationSession):
38
39
  self._accumulated_tokens = TokenUsage()
39
40
 
40
41
  def _process_stdout(self, line: str) -> None:
41
- if not line:
42
+ if not line.strip():
42
43
  return
43
44
  try:
44
45
  data = json.loads(line)
45
46
  except json.JSONDecodeError:
46
- self.stdout_lines.append(line.rstrip())
47
- if not self.silent:
47
+ stripped = line.rstrip()
48
+ self.stdout_lines.append(stripped)
49
+ if self.event_handler and stripped:
50
+ self.event_handler.on_thinking(stripped)
51
+ elif not self.silent:
48
52
  if self._at_line_start:
49
53
  self._log_raw(f"{self.log_prefix} ")
50
- self._log_raw(line.rstrip() + "\n")
54
+ self._log_raw(stripped + "\n")
51
55
  self._at_line_start = True
52
56
  return
53
57
 
54
58
  event = CodexEvent.from_dict(data)
55
59
  if event is None:
60
+ stripped = line.rstrip()
61
+ if self.event_handler and stripped:
62
+ self.event_handler.on_thinking(stripped)
63
+ self.stdout_lines.append(stripped)
56
64
  return
57
65
  self._handle_event(event)
58
66
 
@@ -65,6 +73,13 @@ class CodexGenerationSession(CLIGenerationSession):
65
73
  if isinstance(event, ThreadStartedEvent):
66
74
  if self.session_id is None and event.thread_id:
67
75
  self.session_id = event.thread_id
76
+ if self.event_handler and event.thread_id:
77
+ self.event_handler.on_thinking(f"[codex thread {event.thread_id} started]")
78
+ return
79
+
80
+ if isinstance(event, LifecycleEvent):
81
+ if self.event_handler and event.event_type == "turn.started":
82
+ self.event_handler.on_thinking("[codex turn started]")
68
83
  return
69
84
 
70
85
  if isinstance(event, TextEvent):
@@ -76,12 +91,12 @@ class CodexGenerationSession(CLIGenerationSession):
76
91
  return
77
92
 
78
93
  if isinstance(event, ToolUseEvent):
79
- if event.tool_id:
94
+ if event.tool_name == "execute" and event.tool_id:
80
95
  self.tool_map[event.tool_id] = event.tool_name
81
96
  self.tool_start_times[event.tool_id] = time.time()
82
97
  self.tool_args[event.tool_id] = event.parameters
83
- if self.event_handler:
84
- self.event_handler.on_tool_call(event.tool_name, event.parameters)
98
+ if self.event_handler:
99
+ self.event_handler.on_tool_call(event.tool_name, event.parameters)
85
100
  return
86
101
 
87
102
  if isinstance(event, TurnCompletedEvent):
@@ -96,16 +111,48 @@ class CodexGenerationSession(CLIGenerationSession):
96
111
  total_cost_usd=None,
97
112
  provider="codex",
98
113
  )
114
+ normalized_usage = {
115
+ "input_tokens": event.input_tokens,
116
+ "output_tokens": event.output_tokens,
117
+ "cache_read_input_tokens": event.cached_input_tokens,
118
+ "cache_creation_input_tokens": 0,
119
+ }
120
+ if event.has_usage:
121
+ self.final_usage = normalized_usage
122
+ if self.event_handler is not None and event.has_usage:
123
+ on_usage = getattr(self.event_handler, "on_usage", None)
124
+ if on_usage is not None:
125
+ on_usage(normalized_usage)
126
+ self.event_handler.on_thinking(
127
+ f"[codex turn complete: in={normalized_usage['input_tokens']} "
128
+ f"cached={normalized_usage['cache_read_input_tokens']} "
129
+ f"out={normalized_usage['output_tokens']}]"
130
+ )
131
+ elif self.event_handler is not None:
132
+ self.event_handler.on_thinking("[codex turn complete]")
99
133
  return
100
134
 
101
135
  if isinstance(event, ToolResultEvent):
102
136
  if not event.tool_id:
103
- return
104
- event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
137
+ if event.tool_name is None:
138
+ return
139
+ event.tool_name_resolved = event.tool_name
140
+ args = event.parameters or {}
141
+ duration = None
142
+ if self.event_handler:
143
+ self.event_handler.on_tool_call(event.tool_name, args)
144
+ else:
145
+ event.tool_name_resolved = self.tool_map.get(
146
+ event.tool_id,
147
+ event.tool_name or "Tool",
148
+ )
149
+
150
+ start_time = self.tool_start_times.get(event.tool_id)
151
+ duration = time.time() - start_time if start_time else None
152
+ args = self.tool_args.get(event.tool_id, event.parameters or {})
105
153
 
106
- start_time = self.tool_start_times.get(event.tool_id)
107
- duration = time.time() - start_time if start_time else None
108
- args = self.tool_args.get(event.tool_id, {})
154
+ if event.tool_id not in self.tool_map and self.event_handler and event.tool_name:
155
+ self.event_handler.on_tool_call(event.tool_name, args)
109
156
 
110
157
  self.recorder.add_tool_call(
111
158
  tool=event.tool_name_resolved,
@@ -140,8 +187,20 @@ class CodexGenerationSession(CLIGenerationSession):
140
187
  if isinstance(event, ErrorEvent):
141
188
  self.stdout_lines.append(event.message)
142
189
 
190
+ def _process_stderr(self, line: str) -> None:
191
+ line_stripped = line.rstrip("\n")
192
+ self.stderr_lines.append(line)
193
+ if self.event_handler and line_stripped:
194
+ self.event_handler.on_thinking(f"[codex stderr] {line_stripped}")
195
+ elif not self.silent:
196
+ self.logger.bind(stderr=True).info(f"[STDERR] {line_stripped}")
197
+
143
198
  def run(self, prompt: str) -> str:
144
- super().run(prompt)
199
+ started = time.monotonic()
200
+ try:
201
+ super().run(prompt)
202
+ finally:
203
+ self.duration_ms = int((time.monotonic() - started) * 1000)
145
204
  if self.final_result:
146
205
  return self.final_result
147
206
  return "\n".join(self.stdout_lines)
@@ -27,13 +27,7 @@ class CodexEvent(ABC):
27
27
  return LifecycleEvent(event_type)
28
28
 
29
29
  if event_type == "turn.completed":
30
- usage_raw = data.get("usage")
31
- usage = cast("dict[str, Any]", usage_raw) if isinstance(usage_raw, dict) else {}
32
- return TurnCompletedEvent(
33
- input_tokens=int(usage.get("input_tokens") or 0),
34
- cached_input_tokens=int(usage.get("cached_input_tokens") or 0),
35
- output_tokens=int(usage.get("output_tokens") or 0),
36
- )
30
+ return TurnCompletedEvent.from_usage_payload(data.get("usage"))
37
31
 
38
32
  if event_type in ("item.started", "item.completed"):
39
33
  item_raw = data.get("item")
@@ -52,7 +46,15 @@ class CodexEvent(ABC):
52
46
  text_raw = item.get("text", "")
53
47
  return TextEvent(text=text_raw if isinstance(text_raw, str) else "")
54
48
 
49
+ if item_type == "reasoning":
50
+ if not completed:
51
+ return None
52
+ text_raw = item.get("text", "")
53
+ return TextEvent(text=text_raw if isinstance(text_raw, str) else "")
54
+
55
55
  if item_type == "command_execution":
56
+ command_raw = item.get("command", "")
57
+ command = command_raw if isinstance(command_raw, str) else ""
56
58
  exit_code_raw = item.get("exit_code")
57
59
  exit_code = exit_code_raw if isinstance(exit_code_raw, int) else None
58
60
  status_raw = item.get("status")
@@ -63,23 +65,22 @@ class CodexEvent(ABC):
63
65
  output=item.get("aggregated_output", ""),
64
66
  exit_code=exit_code,
65
67
  status=status,
68
+ tool_name="execute",
69
+ parameters={"command": command},
66
70
  )
67
- command_raw = item.get("command", "")
68
- command = command_raw if isinstance(command_raw, str) else ""
69
71
  return ToolUseEvent(
70
72
  tool_id=item_id,
71
- tool_name="shell",
73
+ tool_name="execute",
72
74
  parameters={"command": command},
73
75
  )
74
76
 
75
77
  status_raw = item.get("status")
76
78
  status = status_raw if isinstance(status_raw, str) else None
77
79
  if completed:
78
- return ToolResultEvent(
80
+ return ToolUseEvent(
79
81
  tool_id=item_id,
80
- output=_summarize_item(item),
81
- exit_code=None,
82
- status=status,
82
+ tool_name=item_type or "item",
83
+ parameters=_item_parameters(item),
83
84
  )
84
85
  return ToolUseEvent(
85
86
  tool_id=item_id,
@@ -171,6 +172,8 @@ class ToolResultEvent(CodexEvent):
171
172
  tool_id: str | None,
172
173
  exit_code: int | None = None,
173
174
  status: str | None = None,
175
+ tool_name: str | None = None,
176
+ parameters: Any = None,
174
177
  ):
175
178
  if isinstance(output, list):
176
179
  items = cast("list[Any]", output)
@@ -180,7 +183,9 @@ class ToolResultEvent(CodexEvent):
180
183
  self.tool_id = tool_id
181
184
  self.exit_code = exit_code
182
185
  self.status = status
183
- self.tool_name_resolved: str = "Tool"
186
+ self.tool_name = tool_name
187
+ self.parameters = parameters
188
+ self.tool_name_resolved: str = tool_name or "Tool"
184
189
 
185
190
  def render(self, log_prefix: str) -> str:
186
191
  if not self.output:
@@ -197,10 +202,27 @@ class TurnCompletedEvent(CodexEvent):
197
202
  input_tokens: int = 0,
198
203
  cached_input_tokens: int = 0,
199
204
  output_tokens: int = 0,
205
+ usage: dict[str, Any] | None = None,
200
206
  ):
201
207
  self.input_tokens = input_tokens
202
208
  self.cached_input_tokens = cached_input_tokens
203
209
  self.output_tokens = output_tokens
210
+ self.usage = usage
211
+
212
+ @classmethod
213
+ def from_usage_payload(cls, usage_raw: Any) -> TurnCompletedEvent:
214
+ usage = cast("dict[str, Any]", usage_raw) if isinstance(usage_raw, dict) else None
215
+ usage_dict = usage or {}
216
+ return cls(
217
+ input_tokens=int(usage_dict.get("input_tokens") or 0),
218
+ cached_input_tokens=int(usage_dict.get("cached_input_tokens") or 0),
219
+ output_tokens=int(usage_dict.get("output_tokens") or 0),
220
+ usage=usage,
221
+ )
222
+
223
+ @property
224
+ def has_usage(self) -> bool:
225
+ return self.usage is not None
204
226
 
205
227
  def render(self, log_prefix: str) -> str | None:
206
228
  return None
@@ -22,3 +22,7 @@ class AgentEventHandler(Protocol):
22
22
  ) -> None:
23
23
  """Handle tool execution result."""
24
24
  ...
25
+
26
+ def on_usage(self, usage: dict[str, Any]) -> None:
27
+ """Handle a per-turn usage update from the underlying CLI."""
28
+ ...
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -135,6 +135,19 @@ class TestClaudeGenerationSession:
135
135
  session._process_stdout(line)
136
136
  assert session.final_result == "all done"
137
137
 
138
+ def test_result_event_captures_final_usage_cost_and_duration(self):
139
+ session = self._make_session()
140
+ line = (
141
+ '{"type":"result","result":"done",'
142
+ '"usage":{"input_tokens":7000,"output_tokens":120},'
143
+ '"total_cost_usd":0.0456,"duration_ms":12345}\n'
144
+ )
145
+ session._process_stdout(line)
146
+ assert session.final_result == "done"
147
+ assert session.final_usage == {"input_tokens": 7000, "output_tokens": 120}
148
+ assert session.total_cost_usd == 0.0456
149
+ assert session.duration_ms == 12345
150
+
138
151
  def test_process_stdout_handles_non_json(self):
139
152
  session = self._make_session()
140
153
  session._process_stdout("some plain text\n")
@@ -162,6 +175,59 @@ class TestClaudeGenerationSession:
162
175
  session._process_stdout(line)
163
176
  handler.on_tool_call.assert_called_once_with("Read", {"path": "/tmp"})
164
177
 
178
+ def test_assistant_usage_forwarded_to_event_handler(self):
179
+ handler = MagicMock()
180
+ session = self._make_session(event_handler=handler)
181
+ line = (
182
+ '{"type":"assistant","message":{"content":'
183
+ '[{"type":"text","text":"ok"}],'
184
+ '"usage":{"input_tokens":14000,"output_tokens":50,'
185
+ '"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}\n'
186
+ )
187
+ session._process_stdout(line)
188
+ handler.on_usage.assert_called_once_with(
189
+ {
190
+ "input_tokens": 14000,
191
+ "output_tokens": 50,
192
+ "cache_creation_input_tokens": 0,
193
+ "cache_read_input_tokens": 0,
194
+ }
195
+ )
196
+
197
+ def test_assistant_without_usage_does_not_call_on_usage(self):
198
+ handler = MagicMock()
199
+ session = self._make_session(event_handler=handler)
200
+ line = (
201
+ '{"type":"assistant","message":{"content":'
202
+ '[{"type":"text","text":"ok"}]}}\n'
203
+ )
204
+ session._process_stdout(line)
205
+ handler.on_usage.assert_not_called()
206
+
207
+ def test_legacy_event_handler_without_on_usage_still_works(self):
208
+ class LegacyHandler:
209
+ def __init__(self):
210
+ self.text_calls = []
211
+
212
+ def on_thinking(self, text):
213
+ self.text_calls.append(text)
214
+
215
+ def on_tool_call(self, tool, args=None):
216
+ pass
217
+
218
+ def on_tool_result(self, tool, stdout="", stderr="", exit_code=None, duration=None):
219
+ pass
220
+
221
+ handler = LegacyHandler()
222
+ session = self._make_session(event_handler=handler)
223
+ line = (
224
+ '{"type":"assistant","message":{"content":'
225
+ '[{"type":"text","text":"hi"}],'
226
+ '"usage":{"input_tokens":500,"output_tokens":10}}}\n'
227
+ )
228
+ session._process_stdout(line)
229
+ assert handler.text_calls == ["hi"]
230
+
165
231
  def test_create_session_returns_claude_session(self, agent):
166
232
  session = agent._create_session(cmd=["claude", "-p"])
167
233
  assert isinstance(session, ClaudeGenerationSession)
@@ -0,0 +1,243 @@
1
+ import json
2
+ from unittest.mock import MagicMock
3
+
4
+ import pytest
5
+
6
+ from agentshim.cli_agent import CLICodingAgent
7
+ from agentshim.codex import CodexCodingAgent, CodexGenerationSession
8
+ from agentshim.mcp_config import HttpMcpServer, StdioMcpServer
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_binaries(monkeypatch):
13
+ """Mock binary discovery and CLI check."""
14
+ monkeypatch.setattr(
15
+ "agentshim.cli_agent.shutil.which",
16
+ lambda cmd, path=None: f"/usr/local/bin/{cmd}",
17
+ )
18
+ monkeypatch.setattr(CLICodingAgent, "_check_cli", lambda self: None)
19
+
20
+
21
+ @pytest.fixture
22
+ def agent(mock_binaries):
23
+ return CodexCodingAgent(model="test-model")
24
+
25
+
26
+ class TestCodexCommandConstruction:
27
+ def test_command_base_flags(self, agent):
28
+ cmd = agent._get_command("test")
29
+ assert "exec" in cmd
30
+ assert "--dangerously-bypass-approvals-and-sandbox" in cmd
31
+ assert "--json" in cmd
32
+ assert cmd[-1] == "-"
33
+
34
+ def test_command_omits_mcp_when_no_servers(self, agent):
35
+ cmd = agent._get_command("test")
36
+ assert "-c" not in cmd
37
+ assert cmd[-1] == "-"
38
+
39
+ def test_mcp_http_server(self, mock_binaries):
40
+ servers = [HttpMcpServer(name="srv", url="http://localhost:9000/sse")]
41
+ agent = CodexCodingAgent(mcp_servers=servers)
42
+ cmd = agent._get_command("test")
43
+ assert "-c" in cmd
44
+ idx = cmd.index("-c")
45
+ assert cmd[idx + 1] == 'mcp_servers.srv.url="http://localhost:9000/sse"'
46
+
47
+ def test_mcp_stdio_server(self, mock_binaries):
48
+ servers = [StdioMcpServer(name="tool", command="npx", args=["-y", "pkg"])]
49
+ agent = CodexCodingAgent(mcp_servers=servers)
50
+ cmd = agent._get_command("test")
51
+ c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
52
+ assert 'mcp_servers.tool.command="npx"' in c_values
53
+ assert 'mcp_servers.tool.args=["-y", "pkg"]' in c_values
54
+
55
+ def test_mcp_stdio_server_with_env(self, mock_binaries):
56
+ servers = [StdioMcpServer(name="t", command="cmd", env={"K1": "v1", "K2": "v2"})]
57
+ agent = CodexCodingAgent(mcp_servers=servers)
58
+ cmd = agent._get_command("test")
59
+ c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
60
+ assert 'mcp_servers.t.env.K1="v1"' in c_values
61
+ assert 'mcp_servers.t.env.K2="v2"' in c_values
62
+
63
+ def test_mcp_multiple_servers(self, mock_binaries):
64
+ servers = [
65
+ HttpMcpServer(name="a", url="http://a"),
66
+ StdioMcpServer(name="b", command="cmd"),
67
+ ]
68
+ agent = CodexCodingAgent(mcp_servers=servers)
69
+ cmd = agent._get_command("test")
70
+ c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
71
+ assert 'mcp_servers.a.url="http://a"' in c_values
72
+ assert 'mcp_servers.b.command="cmd"' in c_values
73
+
74
+
75
+ def _session(event_handler=None) -> CodexGenerationSession:
76
+ session = CodexGenerationSession(
77
+ binary_name="codex",
78
+ env={},
79
+ log_prefix="[Codex]",
80
+ cmd=["codex", "exec", "--json", "-"],
81
+ logger=MagicMock(),
82
+ silent=True,
83
+ event_handler=event_handler,
84
+ )
85
+ return session
86
+
87
+
88
+ class TestCodexGenerationSession:
89
+ def test_thread_started_captures_session_id_and_emits_marker(self):
90
+ handler = MagicMock()
91
+ session = _session(event_handler=handler)
92
+ session._process_stdout(json.dumps({"type": "thread.started", "thread_id": "t-1"}))
93
+ assert session.session_id == "t-1"
94
+ handler.on_thinking.assert_called_once_with("[codex thread t-1 started]")
95
+
96
+ def test_turn_started_emits_marker(self):
97
+ handler = MagicMock()
98
+ session = _session(event_handler=handler)
99
+ session._process_stdout(json.dumps({"type": "turn.started"}))
100
+ handler.on_thinking.assert_called_once_with("[codex turn started]")
101
+
102
+ def test_turn_completed_forwards_normalized_usage(self):
103
+ handler = MagicMock()
104
+ session = _session(event_handler=handler)
105
+ session._process_stdout(
106
+ json.dumps(
107
+ {
108
+ "type": "turn.completed",
109
+ "usage": {
110
+ "input_tokens": 1200,
111
+ "cached_input_tokens": 800,
112
+ "output_tokens": 150,
113
+ },
114
+ }
115
+ )
116
+ )
117
+ handler.on_usage.assert_called_once_with(
118
+ {
119
+ "input_tokens": 1200,
120
+ "output_tokens": 150,
121
+ "cache_read_input_tokens": 800,
122
+ "cache_creation_input_tokens": 0,
123
+ }
124
+ )
125
+ assert session.final_usage == {
126
+ "input_tokens": 1200,
127
+ "output_tokens": 150,
128
+ "cache_read_input_tokens": 800,
129
+ "cache_creation_input_tokens": 0,
130
+ }
131
+ thinking_calls = [call.args[0] for call in handler.on_thinking.call_args_list]
132
+ assert any("turn complete" in text for text in thinking_calls)
133
+
134
+ def test_turn_completed_without_usage_does_not_call_on_usage(self):
135
+ handler = MagicMock()
136
+ session = _session(event_handler=handler)
137
+ session._process_stdout(json.dumps({"type": "turn.completed"}))
138
+ handler.on_usage.assert_not_called()
139
+ assert session.final_usage is None
140
+ handler.on_thinking.assert_called_once_with("[codex turn complete]")
141
+
142
+ def test_legacy_event_handler_without_on_usage_still_works(self):
143
+ class LegacyHandler:
144
+ def on_thinking(self, text):
145
+ pass
146
+
147
+ def on_tool_call(self, tool, args=None):
148
+ pass
149
+
150
+ def on_tool_result(self, tool, stdout="", stderr="", exit_code=None, duration=None):
151
+ pass
152
+
153
+ session = _session(event_handler=LegacyHandler())
154
+ session._process_stdout(
155
+ json.dumps(
156
+ {
157
+ "type": "turn.completed",
158
+ "usage": {
159
+ "input_tokens": 10,
160
+ "cached_input_tokens": 2,
161
+ "output_tokens": 3,
162
+ },
163
+ }
164
+ )
165
+ )
166
+
167
+ def test_reasoning_completed_is_forwarded_as_thinking(self):
168
+ handler = MagicMock()
169
+ session = _session(event_handler=handler)
170
+ session._process_stdout(
171
+ json.dumps(
172
+ {
173
+ "type": "item.completed",
174
+ "item": {"id": "r1", "type": "reasoning", "text": "I should grep for X"},
175
+ }
176
+ )
177
+ )
178
+ handler.on_thinking.assert_called_once_with("I should grep for X")
179
+
180
+ def test_command_execution_completed_without_started_event_emits_call_and_result(self):
181
+ handler = MagicMock()
182
+ session = _session(event_handler=handler)
183
+ session._process_stdout(
184
+ json.dumps(
185
+ {
186
+ "type": "item.completed",
187
+ "item": {
188
+ "type": "command_execution",
189
+ "command": "ls -la",
190
+ "aggregated_output": "file.txt\n",
191
+ },
192
+ }
193
+ )
194
+ )
195
+ handler.on_tool_call.assert_called_once_with("execute", {"command": "ls -la"})
196
+ handler.on_tool_result.assert_called_once_with(
197
+ tool="execute",
198
+ stdout="file.txt\n",
199
+ exit_code=None,
200
+ duration=None,
201
+ )
202
+
203
+ def test_unknown_item_completed_surfaces_tool_call_with_full_payload(self):
204
+ handler = MagicMock()
205
+ session = _session(event_handler=handler)
206
+ session._process_stdout(
207
+ json.dumps(
208
+ {
209
+ "type": "item.completed",
210
+ "item": {
211
+ "id": "fc1",
212
+ "type": "file_change",
213
+ "path": "engine.py",
214
+ "kind": "update",
215
+ },
216
+ }
217
+ )
218
+ )
219
+ handler.on_tool_call.assert_called_once_with(
220
+ "file_change",
221
+ {"path": "engine.py", "kind": "update"},
222
+ )
223
+
224
+ def test_non_json_stdout_line_is_forwarded_to_event_handler(self):
225
+ handler = MagicMock()
226
+ session = _session(event_handler=handler)
227
+ session._process_stdout("starting codex 1.2.3\n")
228
+ handler.on_thinking.assert_called_once_with("starting codex 1.2.3")
229
+ assert session.stdout_lines == ["starting codex 1.2.3"]
230
+
231
+ def test_unknown_json_event_is_forwarded_raw(self):
232
+ handler = MagicMock()
233
+ session = _session(event_handler=handler)
234
+ raw = json.dumps({"type": "item.updated", "item": {"type": "reasoning", "delta": "thinking..."}})
235
+ session._process_stdout(raw)
236
+ handler.on_thinking.assert_called_once_with(raw)
237
+
238
+ def test_stderr_is_forwarded_to_event_handler(self):
239
+ handler = MagicMock()
240
+ session = _session(event_handler=handler)
241
+ session._process_stderr("panic: index out of bounds\n")
242
+ handler.on_thinking.assert_called_once_with("[codex stderr] panic: index out of bounds")
243
+ assert session.stderr_lines == ["panic: index out of bounds\n"]
@@ -77,6 +77,37 @@ class TestClaudeEventFromDict:
77
77
  assert isinstance(event.events[0], TextEvent)
78
78
  assert isinstance(event.events[1], ToolUseEvent)
79
79
 
80
+ def test_assistant_event_captures_usage(self):
81
+ data = {
82
+ "type": "assistant",
83
+ "message": {
84
+ "content": [{"type": "text", "text": "hi"}],
85
+ "usage": {
86
+ "input_tokens": 1234,
87
+ "output_tokens": 56,
88
+ "cache_creation_input_tokens": 100,
89
+ "cache_read_input_tokens": 7,
90
+ },
91
+ },
92
+ }
93
+ event = ClaudeEvent.from_dict(data)
94
+ assert isinstance(event, MultiEvent)
95
+ assert event.usage == {
96
+ "input_tokens": 1234,
97
+ "output_tokens": 56,
98
+ "cache_creation_input_tokens": 100,
99
+ "cache_read_input_tokens": 7,
100
+ }
101
+
102
+ def test_assistant_event_missing_usage_is_none(self):
103
+ data = {
104
+ "type": "assistant",
105
+ "message": {"content": [{"type": "text", "text": "hi"}]},
106
+ }
107
+ event = ClaudeEvent.from_dict(data)
108
+ assert isinstance(event, MultiEvent)
109
+ assert event.usage is None
110
+
80
111
  def test_user_tool_result_event(self):
81
112
  data = {
82
113
  "type": "user",
@@ -337,6 +368,18 @@ class TestCodexEventFromDict:
337
368
  }
338
369
  assert CodexEvent.from_dict(data) is None
339
370
 
371
+ def test_reasoning_completed_is_text(self):
372
+ from agentshim.codex_events import CodexEvent
373
+ from agentshim.codex_events import TextEvent as CodexTextEvent
374
+
375
+ data = {
376
+ "type": "item.completed",
377
+ "item": {"id": "r1", "type": "reasoning", "text": "planning"},
378
+ }
379
+ event = CodexEvent.from_dict(data)
380
+ assert isinstance(event, CodexTextEvent)
381
+ assert event.text == "planning"
382
+
340
383
  def test_command_execution_started_is_tool_use(self):
341
384
  from agentshim.codex_events import CodexEvent
342
385
  from agentshim.codex_events import ToolUseEvent as CodexToolUseEvent
@@ -353,7 +396,7 @@ class TestCodexEventFromDict:
353
396
  event = CodexEvent.from_dict(data)
354
397
  assert isinstance(event, CodexToolUseEvent)
355
398
  assert event.tool_id == "item_1"
356
- assert event.tool_name == "shell"
399
+ assert event.tool_name == "execute"
357
400
  assert event.parameters == {"command": "/bin/bash -lc ls"}
358
401
 
359
402
  def test_command_execution_completed_is_tool_result(self):
@@ -378,17 +421,32 @@ class TestCodexEventFromDict:
378
421
  assert event.exit_code == 0
379
422
  assert event.status == "completed"
380
423
 
381
- def test_generic_item_types_become_tool_events(self):
424
+ def test_generic_item_started_reasoning_is_skipped(self):
382
425
  from agentshim.codex_events import CodexEvent
383
- from agentshim.codex_events import ToolUseEvent as CodexToolUseEvent
384
426
 
385
427
  data = {
386
428
  "type": "item.started",
387
429
  "item": {"id": "r1", "type": "reasoning", "summary": "planning"},
388
430
  }
431
+ assert CodexEvent.from_dict(data) is None
432
+
433
+ def test_generic_item_completed_becomes_tool_use_with_full_payload(self):
434
+ from agentshim.codex_events import CodexEvent
435
+ from agentshim.codex_events import ToolUseEvent as CodexToolUseEvent
436
+
437
+ data = {
438
+ "type": "item.completed",
439
+ "item": {
440
+ "id": "fc1",
441
+ "type": "file_change",
442
+ "path": "engine.py",
443
+ "kind": "update",
444
+ },
445
+ }
389
446
  event = CodexEvent.from_dict(data)
390
447
  assert isinstance(event, CodexToolUseEvent)
391
- assert event.tool_name == "reasoning"
448
+ assert event.tool_name == "file_change"
449
+ assert event.parameters == {"path": "engine.py", "kind": "update"}
392
450
 
393
451
  def test_turn_failed_is_error(self):
394
452
  from agentshim.codex_events import CodexEvent, ErrorEvent
@@ -78,12 +78,30 @@ class TestClaudeSessionUsage:
78
78
  assert session.usage.tokens.turns == 4
79
79
  # Invariant: cached ⊆ input
80
80
  assert session.usage.tokens.cached_input_tokens <= session.usage.tokens.input_tokens
81
+ assert session.final_usage == {
82
+ "input_tokens": 100,
83
+ "output_tokens": 40,
84
+ "cache_creation_input_tokens": 30,
85
+ "cache_read_input_tokens": 20,
86
+ }
87
+ assert session.total_cost_usd == 0.5
88
+
89
+ def test_result_event_populates_duration_ms(self):
90
+ session = _make_session(ClaudeGenerationSession)
91
+ event = ClaudeResultEvent(
92
+ result="done",
93
+ duration_ms=18431,
94
+ )
95
+ session._handle_event(event)
96
+ assert session.duration_ms == 18431
81
97
 
82
98
  def test_result_event_without_usage_degrades_to_zero(self):
83
99
  session = _make_session(ClaudeGenerationSession)
84
100
  session._handle_event(ClaudeResultEvent(result="done"))
85
101
  assert session.usage.provider == "claude"
86
102
  assert session.usage.tokens == TokenUsage()
103
+ assert session.final_usage is None
104
+ assert session.total_cost_usd is None
87
105
 
88
106
 
89
107
  class TestCodexSessionUsage:
@@ -98,6 +116,25 @@ class TestCodexSessionUsage:
98
116
  assert session.usage.tokens.cached_input_tokens == 40
99
117
  assert session.usage.tokens.turns == 2
100
118
 
119
+ def test_turn_completed_populates_final_usage(self):
120
+ session = _make_session(CodexGenerationSession)
121
+ session._handle_event(
122
+ TurnCompletedEvent(
123
+ input_tokens=100,
124
+ cached_input_tokens=10,
125
+ output_tokens=50,
126
+ usage={"input_tokens": 100, "cached_input_tokens": 10, "output_tokens": 50},
127
+ )
128
+ )
129
+
130
+ assert session.final_usage == {
131
+ "input_tokens": 100,
132
+ "output_tokens": 50,
133
+ "cache_read_input_tokens": 10,
134
+ "cache_creation_input_tokens": 0,
135
+ }
136
+ assert session.total_cost_usd is None
137
+
101
138
 
102
139
  class TestOpencodeSessionUsage:
103
140
  def test_step_finish_events_accumulate_with_cache_and_reasoning(self):
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
4
4
 
5
5
  [[package]]
6
6
  name = "agentshim"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "litellm" },
@@ -1,69 +0,0 @@
1
- import pytest
2
-
3
- from agentshim.cli_agent import CLICodingAgent
4
- from agentshim.codex import CodexCodingAgent
5
- from agentshim.mcp_config import HttpMcpServer, StdioMcpServer
6
-
7
-
8
- @pytest.fixture
9
- def mock_binaries(monkeypatch):
10
- """Mock binary discovery and CLI check."""
11
- monkeypatch.setattr(
12
- "agentshim.cli_agent.shutil.which",
13
- lambda cmd, path=None: f"/usr/local/bin/{cmd}",
14
- )
15
- monkeypatch.setattr(CLICodingAgent, "_check_cli", lambda self: None)
16
-
17
-
18
- @pytest.fixture
19
- def agent(mock_binaries):
20
- return CodexCodingAgent(model="test-model")
21
-
22
-
23
- class TestCodexCommandConstruction:
24
- def test_command_base_flags(self, agent):
25
- cmd = agent._get_command("test")
26
- assert "exec" in cmd
27
- assert "--dangerously-bypass-approvals-and-sandbox" in cmd
28
- assert "--json" in cmd
29
- assert cmd[-1] == "-"
30
-
31
- def test_command_omits_mcp_when_no_servers(self, agent):
32
- cmd = agent._get_command("test")
33
- assert "-c" not in cmd
34
- assert cmd[-1] == "-"
35
-
36
- def test_mcp_http_server(self, mock_binaries):
37
- servers = [HttpMcpServer(name="srv", url="http://localhost:9000/sse")]
38
- agent = CodexCodingAgent(mcp_servers=servers)
39
- cmd = agent._get_command("test")
40
- assert "-c" in cmd
41
- idx = cmd.index("-c")
42
- assert cmd[idx + 1] == 'mcp_servers.srv.url="http://localhost:9000/sse"'
43
-
44
- def test_mcp_stdio_server(self, mock_binaries):
45
- servers = [StdioMcpServer(name="tool", command="npx", args=["-y", "pkg"])]
46
- agent = CodexCodingAgent(mcp_servers=servers)
47
- cmd = agent._get_command("test")
48
- c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
49
- assert 'mcp_servers.tool.command="npx"' in c_values
50
- assert 'mcp_servers.tool.args=["-y", "pkg"]' in c_values
51
-
52
- def test_mcp_stdio_server_with_env(self, mock_binaries):
53
- servers = [StdioMcpServer(name="t", command="cmd", env={"K1": "v1", "K2": "v2"})]
54
- agent = CodexCodingAgent(mcp_servers=servers)
55
- cmd = agent._get_command("test")
56
- c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
57
- assert 'mcp_servers.t.env.K1="v1"' in c_values
58
- assert 'mcp_servers.t.env.K2="v2"' in c_values
59
-
60
- def test_mcp_multiple_servers(self, mock_binaries):
61
- servers = [
62
- HttpMcpServer(name="a", url="http://a"),
63
- StdioMcpServer(name="b", command="cmd"),
64
- ]
65
- agent = CodexCodingAgent(mcp_servers=servers)
66
- cmd = agent._get_command("test")
67
- c_values = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-c"]
68
- assert 'mcp_servers.a.url="http://a"' in c_values
69
- assert 'mcp_servers.b.command="cmd"' in c_values
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes