skillbench-cli 0.1.0__py3-none-any.whl

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.
skillbench/progress.py ADDED
@@ -0,0 +1,219 @@
1
+ """What the terminal shows while runs go: a live status with the current turn, the latest tool
2
+ call, elapsed time, an overall bar and an ETA; or, when output isn't a terminal, plain lines."""
3
+
4
+ import json
5
+ import sys
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from rich.console import Console, Group
12
+ from rich.live import Live
13
+ from rich.progress_bar import ProgressBar
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ _RUN_RECORD_FILE_NAME = "run.json"
18
+ _REFRESHES_PER_SECOND = 4
19
+ # The bar never shows a run as done before it is, however long it overruns its estimate.
20
+ _MAX_RUN_FRACTION = 0.95
21
+ _BAR_WIDTH = 30
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RunHeading:
26
+ """What is about to run."""
27
+
28
+ index: int # 1-based position among all runs of this invocation
29
+ total: int
30
+ test_name: str
31
+ model: str | None
32
+ planned_turns: int | None # the prompt plus scripted answers; None if not known up front
33
+ repeat_index: int = 1
34
+ repeat_count: int = 1
35
+
36
+
37
+ class Progress(Protocol):
38
+ def run_started(self, heading: RunHeading, eta: "Eta") -> None: ...
39
+
40
+ def activity(self, turn: int, last_tool: str | None) -> None: ...
41
+
42
+ def run_finished(self, line: str) -> None: ...
43
+
44
+ def pause(self) -> None: ...
45
+
46
+ def resume(self) -> None: ...
47
+
48
+
49
+ def load_history(runs_root: Path) -> dict[str, float]:
50
+ """The most recent duration of each test recorded under runs_root."""
51
+ latest: dict[str, tuple[str, float]] = {}
52
+ if not runs_root.is_dir():
53
+ return {}
54
+ for record_path in runs_root.glob(f"*/{_RUN_RECORD_FILE_NAME}"):
55
+ try:
56
+ record = json.loads(record_path.read_text(encoding="utf-8"))
57
+ key = record["test_name"]
58
+ started, duration = record["start_time"], float(record["duration_s"])
59
+ except (OSError, ValueError, KeyError, TypeError):
60
+ continue
61
+ if duration <= 0 or record.get("stop_reason") == "setup_failed":
62
+ continue
63
+ if key not in latest or started > latest[key][0]:
64
+ latest[key] = (started, duration)
65
+ return {key: duration for key, (_, duration) in latest.items()}
66
+
67
+
68
+ class Eta:
69
+ """Estimates how long the rest of the invocation takes. Each run's estimate is its test's
70
+ last recorded duration, else the mean of runs finished so far; if any run
71
+ still has no estimate, there is no ETA rather than a made-up one."""
72
+
73
+ def __init__(self, history: dict[str, float], run_keys: list[str]) -> None:
74
+ self._history = history
75
+ self._run_keys = run_keys
76
+ self._finished: list[float] = []
77
+ self._current = 0 # index into run_keys of the run in progress
78
+
79
+ def start_run(self, index: int) -> None:
80
+ self._current = index
81
+
82
+ def finish_run(self, duration_s: float) -> None:
83
+ self._finished.append(duration_s)
84
+
85
+ def estimate(self, key: str) -> float | None:
86
+ if key in self._history:
87
+ return self._history[key]
88
+ if self._finished:
89
+ return sum(self._finished) / len(self._finished)
90
+ return None
91
+
92
+ def remaining_s(self, elapsed_s: float) -> float | None:
93
+ estimates = [self.estimate(key) for key in self._run_keys[self._current :]]
94
+ if any(estimate is None for estimate in estimates):
95
+ return None
96
+ current, *later = estimates
97
+ return max(current - elapsed_s, 0.0) + sum(later)
98
+
99
+ def fraction_done(self, elapsed_s: float) -> float:
100
+ if not self._run_keys:
101
+ return 1.0
102
+ estimate = self.estimate(self._run_keys[self._current])
103
+ current_fraction = min(elapsed_s / estimate, _MAX_RUN_FRACTION) if estimate else 0.0
104
+ return (self._current + current_fraction) / len(self._run_keys)
105
+
106
+
107
+ def format_seconds(seconds: float) -> str:
108
+ seconds = int(round(seconds))
109
+ if seconds < 60:
110
+ return f"{seconds}s"
111
+ minutes, seconds = divmod(seconds, 60)
112
+ if minutes < 60:
113
+ return f"{minutes}m {seconds:02d}s"
114
+ hours, minutes = divmod(minutes, 60)
115
+ return f"{hours}h {minutes:02d}m"
116
+
117
+
118
+ def describe_run(heading: RunHeading) -> str:
119
+ repeat = (
120
+ f" (repeat {heading.repeat_index}/{heading.repeat_count})"
121
+ if heading.repeat_count > 1
122
+ else ""
123
+ )
124
+ model = heading.model or "default model"
125
+ return f"[{heading.index}/{heading.total}] {heading.test_name}{repeat} {model}"
126
+
127
+
128
+ class PlainProgress:
129
+ """For pipes and CI: one line when a run starts and one when it ends, no escape codes."""
130
+
131
+ def run_started(self, heading: RunHeading, eta: Eta) -> None:
132
+ print(describe_run(heading), flush=True)
133
+
134
+ def activity(self, turn: int, last_tool: str | None) -> None:
135
+ pass
136
+
137
+ def run_finished(self, line: str) -> None:
138
+ print(line, flush=True)
139
+
140
+ def pause(self) -> None:
141
+ pass
142
+
143
+ def resume(self) -> None:
144
+ pass
145
+
146
+
147
+ class LiveProgress:
148
+ """A status block redrawn in place while a run goes, replaced by its result line after."""
149
+
150
+ def __init__(self) -> None:
151
+ self._console = Console(highlight=False)
152
+ self._live: Live | None = None
153
+ self._heading = RunHeading(0, 0, "", None, None)
154
+ self._eta = Eta({}, [])
155
+ self._started = 0.0
156
+ self._turn = 1
157
+ self._last_tool: str | None = None
158
+
159
+ def run_started(self, heading: RunHeading, eta: Eta) -> None:
160
+ self._heading, self._eta = heading, eta
161
+ self._started = time.monotonic()
162
+ self._turn, self._last_tool = 1, None
163
+ self._live = Live(
164
+ get_renderable=self._render,
165
+ console=self._console,
166
+ refresh_per_second=_REFRESHES_PER_SECOND,
167
+ transient=True,
168
+ )
169
+ self._live.start()
170
+
171
+ def activity(self, turn: int, last_tool: str | None) -> None:
172
+ if turn != self._turn:
173
+ self._turn, self._last_tool = turn, None
174
+ self._last_tool = last_tool or self._last_tool
175
+
176
+ def run_finished(self, line: str) -> None:
177
+ self.pause()
178
+ self._live = None
179
+ self._console.print(line, markup=False)
180
+
181
+ def pause(self) -> None:
182
+ if self._live is not None:
183
+ self._live.stop()
184
+
185
+ def resume(self) -> None:
186
+ if self._live is not None:
187
+ self._live.start()
188
+
189
+ def _render(self) -> Group:
190
+ elapsed = time.monotonic() - self._started
191
+ return Group(
192
+ Text(describe_run(self._heading), style="bold"),
193
+ Text(self._activity_line(elapsed), style="dim"),
194
+ self._bar(elapsed),
195
+ )
196
+
197
+ def _activity_line(self, elapsed: float) -> str:
198
+ planned = self._heading.planned_turns
199
+ turns = f"{self._turn}/{planned}" if planned else str(self._turn)
200
+ doing = self._last_tool or "thinking"
201
+ return f" turn {turns} · {doing} · {format_seconds(elapsed)} elapsed"
202
+
203
+ def _bar(self, elapsed: float) -> Table:
204
+ fraction = self._eta.fraction_done(elapsed)
205
+ remaining = self._eta.remaining_s(elapsed)
206
+ estimate = "" if remaining is None else f"est. {format_seconds(remaining)} left"
207
+ bar = Table.grid(padding=(0, 1))
208
+ bar.add_row(
209
+ Text(" "),
210
+ ProgressBar(total=1.0, completed=fraction, width=_BAR_WIDTH),
211
+ Text(f"{fraction:>4.0%}"),
212
+ Text(estimate),
213
+ )
214
+ return bar
215
+
216
+
217
+ def make_progress() -> Progress:
218
+ """Live in a terminal, plain lines otherwise."""
219
+ return LiveProgress() if sys.stdout.isatty() else PlainProgress()
skillbench/runner.py ADDED
@@ -0,0 +1,471 @@
1
+ """Running one test as a conversation with Claude Code and recording it under runs/<run-id>/."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ import uuid
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field, replace
12
+ from datetime import datetime
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from skillbench import claude, skills
18
+ from skillbench.assertions import Assertion, AssertionResult, RunFacts, evaluate_assertions
19
+ from skillbench.claude import TurnInfo, UserMessage
20
+ from skillbench.config import TestSpec
21
+ from skillbench.progress import Progress
22
+ from skillbench.workspace import FixtureError, create_workspace
23
+
24
+ _IS_WINDOWS = os.name == "nt"
25
+ _POLL_INTERVAL_S = 0.5
26
+ # Typed in place of a live answer, these end the conversation.
27
+ _END_COMMANDS = ("", "/done")
28
+
29
+ # Shows the agent's final text to the user and returns what they typed (None at end of input).
30
+ AskUser = Callable[[str], str | None]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RunOptions:
35
+ """Choices made per invocation rather than in the test file."""
36
+
37
+ executable: str # the claude CLI
38
+ eval_id: str # shared by every run of one invocation
39
+ model_override: str | None = None
40
+ ask_user: AskUser | None = None # None when there is no terminal to ask live answers in
41
+ progress: Progress | None = None # told about each turn's activity as it runs
42
+
43
+ def model_for(self, spec: TestSpec) -> str | None:
44
+ return self.model_override or spec.model
45
+
46
+
47
+ def run_test(
48
+ spec: TestSpec, runs_root: Path, options: RunOptions, repeat_index: int = 1
49
+ ) -> dict[str, Any]:
50
+ """Run `spec` once, check its assertions, and write run.json and the raw output under
51
+ runs_root; return the record. A fixture or skill that can't be set up gives an `error`
52
+ record without launching the agent."""
53
+ run_dir = runs_root / _new_run_id(spec.name)
54
+ (run_dir / "raw").mkdir(parents=True)
55
+ record = {
56
+ "run_id": run_dir.name,
57
+ "eval_id": options.eval_id,
58
+ "backend": "claude",
59
+ **_run_context(spec, repeat_index),
60
+ }
61
+ setup_started = datetime.now().astimezone()
62
+ workspace = None
63
+ try:
64
+ workspace, record["fixture_commit"] = create_workspace(spec.fixture, spec.fixture_exclude)
65
+ spec = _prepare_for_workspace(spec, workspace)
66
+ except (FixtureError, skills.SkillNotFoundError) as error:
67
+ record |= _setup_failed_record(spec, options, workspace, setup_started, error)
68
+ else:
69
+ conversation = _converse(spec, options, workspace, run_dir / "raw")
70
+ record |= _conversation_record(spec, options, workspace, conversation)
71
+ (run_dir / "run.json").write_text(json.dumps(record, indent=2), encoding="utf-8")
72
+ return record
73
+
74
+
75
+ def _run_context(spec: TestSpec, repeat_index: int) -> dict[str, Any]:
76
+ """What the run was asked to do, recorded alongside what happened."""
77
+ return {
78
+ "kind": spec.kind,
79
+ "skill": spec.skill,
80
+ "trigger": None if spec.trigger is None else vars(spec.trigger),
81
+ "fixture": None if spec.fixture is None else str(spec.fixture),
82
+ "fixture_commit": None,
83
+ "repeat_index": repeat_index,
84
+ "repeat_count": spec.repeat,
85
+ "min_pass_rate": spec.min_pass_rate,
86
+ }
87
+
88
+
89
+ def _prepare_for_workspace(spec: TestSpec, workspace: Path) -> TestSpec:
90
+ """Load every plugin and skill the repo has, and turn `skill:` into the prompt that runs
91
+ it (or, for a trigger run, the trigger prompt and the check that the skill did or didn't
92
+ fire). Raises SkillNotFoundError."""
93
+ found = skills.discover(workspace)
94
+ skills.make_visible(workspace, found)
95
+ spec = replace(spec, plugin_dirs=found.plugin_dirs)
96
+ if spec.skill is None:
97
+ return spec
98
+ target = skills.resolve(workspace, found, spec.skill)
99
+ if spec.trigger is None:
100
+ return replace(spec, prompt=f"{target.invocation} {spec.skill_input}".strip())
101
+ check = "skill_invoked" if spec.trigger.should_invoke else "skill_not_invoked"
102
+ return replace(
103
+ spec,
104
+ prompt=spec.trigger.prompt,
105
+ answers=[],
106
+ is_interactive=False,
107
+ assertions=[Assertion(type=check, expected=target.name)],
108
+ )
109
+
110
+
111
+ # --- the conversation ----------------------------------------------------------------------
112
+
113
+
114
+ class _StopReason(StrEnum):
115
+ """Why a conversation ended; recorded in run.json as `stop_reason`."""
116
+
117
+ NO_MORE_ANSWERS = "no_more_answers"
118
+ TURN_FAILED = "turn_failed"
119
+ NEEDS_LIVE_ANSWER = "needs_live_answer"
120
+ NO_SESSION_ID = "no_session_id"
121
+ TRIGGER_DECIDED = "trigger_decided" # a trigger run stopped at the first skill call
122
+ SETUP_FAILED = "setup_failed" # the workspace or skill could not be prepared
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class _Stop:
127
+ reason: _StopReason
128
+ error: str | None = None # set when the stop makes the run an error
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class _Turn:
133
+ user_message: UserMessage | None # None for the first turn, which sends the test prompt
134
+ outcome: "_ProcessOutcome"
135
+ info: TurnInfo
136
+
137
+
138
+ @dataclass
139
+ class _Conversation:
140
+ turns: list[_Turn] = field(default_factory=list)
141
+ stop: _Stop = _Stop(_StopReason.NO_MORE_ANSWERS)
142
+
143
+ @property
144
+ def session_id(self) -> str | None:
145
+ reported = [turn.info.session_id for turn in self.turns if turn.info.session_id]
146
+ return reported[-1] if reported else None
147
+
148
+
149
+ class _LiveAnswerUnavailableError(Exception):
150
+ """A live answer is needed but there is no terminal to ask in."""
151
+
152
+
153
+ def _converse(spec: TestSpec, options: RunOptions, workspace: Path, raw_dir: Path) -> _Conversation:
154
+ """Send the prompt, then each answer as a follow-up turn in the same session, until the
155
+ answers run out or a turn fails."""
156
+ launcher = _TurnLauncher(spec, options, workspace, raw_dir)
157
+ answers = _AnswerSource(spec.answers, spec.is_interactive, options.ask_user)
158
+ conversation = _Conversation()
159
+ user_message: UserMessage | None = None
160
+ while True:
161
+ message = spec.prompt if user_message is None else user_message.text
162
+ turn_number = len(conversation.turns) + 1
163
+ outcome, info = launcher.run_turn(turn_number, message, conversation.session_id)
164
+ conversation.turns.append(_Turn(user_message, outcome, info))
165
+ stop = _stop_before_next_turn(conversation, answers)
166
+ if stop is not None:
167
+ conversation.stop = stop
168
+ return conversation
169
+ try:
170
+ user_message = answers.next_answer(claude.final_text(info.trace["items"]))
171
+ except _LiveAnswerUnavailableError as error:
172
+ conversation.stop = _Stop(_StopReason.NEEDS_LIVE_ANSWER, f"{spec.source_path}: {error}")
173
+ return conversation
174
+ if user_message is None:
175
+ return conversation
176
+
177
+
178
+ def _stop_before_next_turn(conversation: _Conversation, answers: "_AnswerSource") -> _Stop | None:
179
+ """Why no further turn may follow the last one, or None if one may."""
180
+ last_outcome = conversation.turns[-1].outcome
181
+ if last_outcome.stopped_early:
182
+ return _Stop(_StopReason.TRIGGER_DECIDED)
183
+ if last_outcome.run_status != "completed":
184
+ return _Stop(_StopReason.TURN_FAILED)
185
+ if not answers.has_more:
186
+ return _Stop(_StopReason.NO_MORE_ANSWERS)
187
+ if conversation.session_id is None:
188
+ turn_count = len(conversation.turns)
189
+ error = f"turn {turn_count} reported no session id, so the next answer cannot resume it"
190
+ return _Stop(_StopReason.NO_SESSION_ID, error)
191
+ return None
192
+
193
+
194
+ class _AnswerSource:
195
+ """Hands out scripted answers in order; once they run out, asks the user if the test is
196
+ interactive."""
197
+
198
+ def __init__(self, scripted: list[str], is_interactive: bool, ask_user: AskUser | None):
199
+ self._scripted = list(scripted)
200
+ self._is_interactive = is_interactive
201
+ self._ask_user = ask_user
202
+
203
+ @property
204
+ def has_more(self) -> bool:
205
+ """Whether the conversation would continue after this turn."""
206
+ return bool(self._scripted) or self._is_interactive
207
+
208
+ def next_answer(self, agent_final_text: str | None) -> UserMessage | None:
209
+ """The next answer, or None when the conversation is over. Raises
210
+ _LiveAnswerUnavailableError if the user must be asked but cannot be."""
211
+ if self._scripted:
212
+ return UserMessage(text=self._scripted.pop(0), source="scripted")
213
+ if not self._is_interactive:
214
+ return None
215
+ if self._ask_user is None:
216
+ raise _LiveAnswerUnavailableError(
217
+ "the agent is waiting for an answer but stdin is not a terminal, "
218
+ "so scripted answers are required (add them under 'answers:')"
219
+ )
220
+ typed = self._ask_user(agent_final_text or "")
221
+ if typed is None or typed.strip() in _END_COMMANDS:
222
+ self._is_interactive = False
223
+ return None
224
+ return UserMessage(text=typed.strip(), source="live")
225
+
226
+
227
+ class _TurnLauncher:
228
+ """Launches each turn of one run: the same CLI, flags and workspace every time."""
229
+
230
+ def __init__(self, spec: TestSpec, options: RunOptions, workspace: Path, raw_dir: Path) -> None:
231
+ self._spec = spec
232
+ self._options = options
233
+ self._workspace = workspace
234
+ self._raw_dir = raw_dir
235
+
236
+ def run_turn(
237
+ self, turn_number: int, message: str, session_id: str | None
238
+ ) -> tuple["_ProcessOutcome", TurnInfo]:
239
+ command = claude.turn_command(
240
+ self._options.executable,
241
+ message,
242
+ session_id,
243
+ self._options.model_for(self._spec),
244
+ self._spec.plugin_dirs,
245
+ )
246
+ stdout_path = self._raw_dir / f"{turn_number}.jsonl"
247
+ watcher = _TurnWatcher(stdout_path, turn_number, self._options.progress)
248
+ outcome = _run_process(
249
+ command, self._workspace, stdout_path, self._spec.timeout_s, self._stop_check(watcher)
250
+ )
251
+ return outcome, claude.parse_stream(stdout_path.read_bytes())
252
+
253
+ def _stop_check(self, watcher: "_TurnWatcher") -> Callable[[], bool] | None:
254
+ """What to check while the turn runs: a trigger run stops at its first skill call;
255
+ otherwise the watcher only reports progress, if anyone is listening."""
256
+ if self._spec.trigger is not None:
257
+ return watcher.has_invoked_a_skill
258
+ return watcher.report if self._options.progress is not None else None
259
+
260
+
261
+ class _TurnWatcher:
262
+ """Re-reads a running turn's output whenever it grows, to report its latest tool call and
263
+ to see whether a skill has been invoked yet."""
264
+
265
+ def __init__(self, stdout_path: Path, turn_number: int, progress: Progress | None) -> None:
266
+ self._stdout_path = stdout_path
267
+ self._turn_number = turn_number
268
+ self._progress = progress
269
+ self._seen_size = -1
270
+ self._tool_names: list[str] = []
271
+ if progress is not None:
272
+ progress.activity(turn_number, None)
273
+
274
+ def report(self) -> bool:
275
+ """Tell progress about the newest tool call; never asks for the turn to stop."""
276
+ self._refresh()
277
+ return False
278
+
279
+ def has_invoked_a_skill(self) -> bool:
280
+ self._refresh()
281
+ return claude.SKILL_TOOL in self._tool_names
282
+
283
+ def _refresh(self) -> None:
284
+ try:
285
+ size = self._stdout_path.stat().st_size
286
+ except OSError:
287
+ return
288
+ if size == self._seen_size:
289
+ return
290
+ self._seen_size = size
291
+ trace = claude.parse_stream(self._stdout_path.read_bytes()).trace
292
+ self._tool_names = [call["name"] for call in claude.walk_tool_calls(trace["items"])]
293
+ if self._progress is not None:
294
+ last_tool = self._tool_names[-1] if self._tool_names else None
295
+ self._progress.activity(self._turn_number, last_tool)
296
+
297
+
298
+ # --- the run record ------------------------------------------------------------------------
299
+
300
+
301
+ def _conversation_record(
302
+ spec: TestSpec, options: RunOptions, workspace: Path, conversation: _Conversation
303
+ ) -> dict[str, Any]:
304
+ first_turn, last_turn = conversation.turns[0], conversation.turns[-1]
305
+ trace = claude.merge_turns(
306
+ [(turn.user_message, turn.info.trace) for turn in conversation.turns]
307
+ )
308
+ facts = RunFacts(
309
+ trace=trace, workspace=workspace, prompt=spec.prompt, loaded_skills=first_turn.info.skills
310
+ )
311
+ assertion_results = evaluate_assertions(spec.assertions, facts)
312
+ run_status = last_turn.outcome.run_status
313
+ return {
314
+ "test_name": spec.name,
315
+ "test_file": str(spec.source_path),
316
+ "prompt": spec.prompt,
317
+ "model": options.model_for(spec) or first_turn.info.reported_model,
318
+ "start_time": first_turn.outcome.start_time.isoformat(),
319
+ "end_time": last_turn.outcome.end_time.isoformat(),
320
+ # Time the CLI spent running; excludes time spent waiting for live answers.
321
+ "duration_s": round(sum(turn.outcome.duration_s for turn in conversation.turns), 3),
322
+ "session_id": conversation.session_id,
323
+ "status": _test_status(run_status, conversation.stop.error, assertion_results),
324
+ "run_status": run_status,
325
+ "exit_code": last_turn.outcome.exit_code,
326
+ "conversation_turns": len(conversation.turns),
327
+ "stop_reason": conversation.stop.reason,
328
+ "error": conversation.stop.error,
329
+ "workspace": str(workspace),
330
+ "plugins": first_turn.info.plugins,
331
+ "skills": first_turn.info.skills,
332
+ "assertions": [result.to_json() for result in assertion_results],
333
+ "trace": trace,
334
+ }
335
+
336
+
337
+ def _setup_failed_record(
338
+ spec: TestSpec,
339
+ options: RunOptions,
340
+ workspace: Path | None,
341
+ started: datetime,
342
+ error: Exception,
343
+ ) -> dict[str, Any]:
344
+ """A run that never reached the agent: the fixture or skill couldn't be prepared."""
345
+ return {
346
+ "test_name": spec.name,
347
+ "test_file": str(spec.source_path),
348
+ "prompt": spec.prompt,
349
+ "model": options.model_for(spec),
350
+ "start_time": started.isoformat(),
351
+ "end_time": datetime.now().astimezone().isoformat(),
352
+ "duration_s": 0.0,
353
+ "session_id": None,
354
+ "status": "error",
355
+ "run_status": "not_started",
356
+ "exit_code": None,
357
+ "conversation_turns": 0,
358
+ "stop_reason": _StopReason.SETUP_FAILED,
359
+ "error": f"{spec.source_path}: {error}",
360
+ "workspace": None if workspace is None else str(workspace),
361
+ "plugins": [],
362
+ "skills": [],
363
+ "assertions": [],
364
+ "trace": claude.empty_trace(),
365
+ }
366
+
367
+
368
+ def _test_status(
369
+ run_status: str, conversation_error: str | None, assertion_results: list[AssertionResult]
370
+ ) -> str:
371
+ """`error` if the CLI or the conversation did not finish cleanly, else `fail` if any
372
+ assertion failed."""
373
+ if run_status != "completed" or conversation_error is not None:
374
+ return "error"
375
+ return "pass" if all(result.passed for result in assertion_results) else "fail"
376
+
377
+
378
+ def _new_run_id(test_name: str) -> str:
379
+ safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", test_name).strip("-")[:60] or "test"
380
+ return f"{datetime.now():%Y%m%d-%H%M%S}-{safe_name}-{uuid.uuid4().hex[:6]}"
381
+
382
+
383
+ # --- the subprocess ------------------------------------------------------------------------
384
+
385
+
386
+ @dataclass(frozen=True)
387
+ class _ProcessOutcome:
388
+ exit_code: int | None # None when the process was killed
389
+ timed_out: bool
390
+ start_time: datetime
391
+ end_time: datetime
392
+ duration_s: float
393
+ stopped_early: bool = False # killed on purpose once the run had what it needed
394
+
395
+ @property
396
+ def run_status(self) -> str:
397
+ if self.timed_out:
398
+ return "timeout"
399
+ return "completed" if self.stopped_early or self.exit_code == 0 else "error"
400
+
401
+
402
+ @dataclass(frozen=True)
403
+ class _Ending:
404
+ exit_code: int | None = None
405
+ timed_out: bool = False
406
+ stopped_early: bool = False
407
+
408
+
409
+ def _run_process(
410
+ command: list[str],
411
+ cwd: Path,
412
+ stdout_path: Path,
413
+ timeout_s: float,
414
+ should_stop: Callable[[], bool] | None,
415
+ ) -> _ProcessOutcome:
416
+ """Run to completion or timeout, or until `should_stop` says so. Stdout streams straight to
417
+ disk so partial output survives and can be read while the turn runs."""
418
+ start_time = datetime.now().astimezone()
419
+ started = time.monotonic()
420
+ stderr_path = stdout_path.with_suffix(".stderr.txt")
421
+ with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file:
422
+ process = subprocess.Popen(
423
+ command,
424
+ cwd=cwd,
425
+ stdin=subprocess.DEVNULL,
426
+ stdout=stdout_file,
427
+ stderr=stderr_file,
428
+ start_new_session=not _IS_WINDOWS,
429
+ )
430
+ ending = _wait(process, started + timeout_s, should_stop)
431
+ if ending.exit_code is None:
432
+ _kill_process_tree(process)
433
+ return _ProcessOutcome(
434
+ exit_code=ending.exit_code,
435
+ timed_out=ending.timed_out,
436
+ start_time=start_time,
437
+ end_time=datetime.now().astimezone(),
438
+ duration_s=round(time.monotonic() - started, 3),
439
+ stopped_early=ending.stopped_early,
440
+ )
441
+
442
+
443
+ def _wait(
444
+ process: subprocess.Popen[bytes], deadline: float, should_stop: Callable[[], bool] | None
445
+ ) -> _Ending:
446
+ """Wait for the process to exit, the deadline to pass, or `should_stop` to say so."""
447
+ while (remaining := deadline - time.monotonic()) > 0:
448
+ wait_s = remaining if should_stop is None else min(_POLL_INTERVAL_S, remaining)
449
+ try:
450
+ return _Ending(exit_code=process.wait(timeout=wait_s))
451
+ except subprocess.TimeoutExpired:
452
+ if should_stop is not None and should_stop():
453
+ return _Ending(stopped_early=True)
454
+ return _Ending(timed_out=True)
455
+
456
+
457
+ def _kill_process_tree(process: subprocess.Popen[bytes]) -> None:
458
+ """The agent CLI spawns its own children (tools, subagents); kill all of them."""
459
+ if _IS_WINDOWS:
460
+ # taskkill fails if the process already exited; the poll below handles that case.
461
+ subprocess.run(
462
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
463
+ stdout=subprocess.DEVNULL,
464
+ stderr=subprocess.DEVNULL,
465
+ check=False,
466
+ )
467
+ if process.poll() is None:
468
+ process.kill()
469
+ else:
470
+ os.killpg(process.pid, signal.SIGKILL)
471
+ process.wait()