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.
- {agentshim-0.3.0 → agentshim-0.3.1}/PKG-INFO +1 -1
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/agent.py +7 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/events.py +11 -2
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/cli_agent.py +5 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/agent.py +72 -13
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/events.py +37 -15
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/events.py +4 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/pyproject.toml +1 -1
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_claude.py +66 -0
- agentshim-0.3.1/tests/unit/test_agent_cli_codex.py +243 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_event_parsing.py +62 -4
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_cli_agent_usage.py +37 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/uv.lock +1 -1
- agentshim-0.3.0/tests/unit/test_agent_cli_codex.py +0 -69
- {agentshim-0.3.0 → agentshim-0.3.1}/.github/workflows/ci.yml +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/.github/workflows/publish.yml +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/.gitignore +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/README.md +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/base.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/hooks/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/claude_events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/codex_events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/agent.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot/events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/copilot_events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/agent.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini/events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/gemini_events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/llm_client.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/mcp_config.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/__init__.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/agent.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode/events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/opencode_events.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/py.typed +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/sandbox.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/subagent.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/trajectory.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/usage.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/agentshim/utils.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/session_turn_1.jsonl +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/session_turn_2_resumed.jsonl +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/streaming_dedup.jsonl +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/fixtures/copilot/tool_and_usage.jsonl +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_check_cli.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/cli_agent/test_cli_prompt_passing.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/conftest.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_claude_stream.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_fixture.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/llm/test_gemini_stream.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_copilot.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_copilot_fixtures.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_mcp_unsupported.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_resume.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_agent_cli_sandbox.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_facade.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_coding_agent_recorder_default.py +0 -0
- {agentshim-0.3.0 → agentshim-0.3.1}/tests/unit/test_mcp_config.py +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
|
-
|
|
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__(
|
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
80
|
+
return ToolUseEvent(
|
|
79
81
|
tool_id=item_id,
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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
|
|
@@ -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 == "
|
|
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
|
|
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 == "
|
|
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):
|
|
@@ -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
|
|
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
|
|
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
|