workflowval 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.
Files changed (59) hide show
  1. workflowval/__init__.py +3 -0
  2. workflowval/cli.py +584 -0
  3. workflowval/gen/__init__.py +194 -0
  4. workflowval/gen/source.py +109 -0
  5. workflowval/gen/style.md +17 -0
  6. workflowval/gen/templates/.gitkeep +0 -0
  7. workflowval/gen/templates/conftest.py.tmpl +332 -0
  8. workflowval/loop/__init__.py +55 -0
  9. workflowval/loop/runner.py +488 -0
  10. workflowval/loop/stats.py +178 -0
  11. workflowval/loop/trace.py +29 -0
  12. workflowval/models/__init__.py +19 -0
  13. workflowval/models/base.py +68 -0
  14. workflowval/models/fake.py +55 -0
  15. workflowval/models/litellm_adapter.py +153 -0
  16. workflowval/pipeline.py +906 -0
  17. workflowval/planner/__init__.py +62 -0
  18. workflowval/prompts/__init__.py +34 -0
  19. workflowval/prompts/planner.md +37 -0
  20. workflowval/prompts/reviewer.md +30 -0
  21. workflowval/prompts/task_answer_review.md +14 -0
  22. workflowval/prompts/task_finding.md +17 -0
  23. workflowval/prompts/task_fix_test.md +6 -0
  24. workflowval/prompts/task_fix_workflow.md +7 -0
  25. workflowval/prompts/task_patch.md +9 -0
  26. workflowval/prompts/task_plan.md +13 -0
  27. workflowval/prompts/task_plan_gaps.md +6 -0
  28. workflowval/prompts/task_report.md +9 -0
  29. workflowval/prompts/task_review_plan.md +17 -0
  30. workflowval/prompts/task_review_results.md +19 -0
  31. workflowval/prompts/task_triage.md +9 -0
  32. workflowval/prompts/writer.md +11 -0
  33. workflowval/py.typed +0 -0
  34. workflowval/redact.py +97 -0
  35. workflowval/report/__init__.py +162 -0
  36. workflowval/report/curl.py +75 -0
  37. workflowval/schemas/__init__.py +78 -0
  38. workflowval/schemas/_base.py +43 -0
  39. workflowval/schemas/auth.py +66 -0
  40. workflowval/schemas/labels.py +31 -0
  41. workflowval/schemas/results.py +222 -0
  42. workflowval/schemas/review.py +94 -0
  43. workflowval/schemas/state.py +32 -0
  44. workflowval/schemas/stats.py +36 -0
  45. workflowval/schemas/workflow.py +148 -0
  46. workflowval/spec/__init__.py +38 -0
  47. workflowval/spec/graph.py +196 -0
  48. workflowval/spec/loader.py +102 -0
  49. workflowval/spec/validation.py +98 -0
  50. workflowval/testrun.py +159 -0
  51. workflowval/tools/__init__.py +6 -0
  52. workflowval/tools/base.py +49 -0
  53. workflowval/tools/registry.py +637 -0
  54. workflowval/triage/__init__.py +199 -0
  55. workflowval-0.1.0.dist-info/METADATA +215 -0
  56. workflowval-0.1.0.dist-info/RECORD +59 -0
  57. workflowval-0.1.0.dist-info/WHEEL +4 -0
  58. workflowval-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,906 @@
1
+ """The `workflowval test` pipeline: plan, render tests, run, label, fix, and write results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass, field, replace
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from workflowval.gen import (
15
+ GenerationError,
16
+ generate,
17
+ hash_line,
18
+ render_conftest,
19
+ test_path,
20
+ )
21
+ from workflowval.loop import (
22
+ Budget,
23
+ CodeStep,
24
+ FixLoop,
25
+ Pipeline,
26
+ RunContext,
27
+ RunResult,
28
+ StopReason,
29
+ StopRun,
30
+ TaskLimitReached,
31
+ TestOutcome,
32
+ make_role,
33
+ run,
34
+ )
35
+ from workflowval.models import Model
36
+ from workflowval.planner import load_workflows, plan, plan_call_limit, uncovered_operations
37
+ from workflowval.prompts import render_prompt
38
+ from workflowval.report import write_reports
39
+ from workflowval.schemas import (
40
+ Action,
41
+ ActionKind,
42
+ Answer,
43
+ AuthConfig,
44
+ Basis,
45
+ Confidence,
46
+ Decision,
47
+ Evidence,
48
+ Finding,
49
+ HttpExchange,
50
+ KnownIssue,
51
+ Label,
52
+ LoginAuth,
53
+ ReviewLog,
54
+ RunResults,
55
+ RunState,
56
+ TestResult,
57
+ TestStatus,
58
+ Verdict,
59
+ Workflow,
60
+ atomic_write_text,
61
+ load_json,
62
+ save_json,
63
+ )
64
+ from workflowval.spec import (
65
+ ResponseChecker,
66
+ SpecError,
67
+ SpecGraph,
68
+ build_graph,
69
+ load_raw_spec,
70
+ load_spec,
71
+ resolve_base_url,
72
+ spec_hash,
73
+ )
74
+ from workflowval.testrun import Outcome, is_reachable, run_tests, test_id
75
+ from workflowval.tools import ToolContext
76
+ from workflowval.triage import (
77
+ MismatchGroup,
78
+ StoredVerdict,
79
+ classify,
80
+ group_mismatches,
81
+ label_failures,
82
+ read_verdicts,
83
+ request_patch,
84
+ request_report,
85
+ review_finding,
86
+ )
87
+
88
+ PLANNER_TOOLS = frozenset({"read_spec", "read_docs", "read_workflows", "write_workflow"})
89
+ REVIEWER_TOOLS = frozenset(
90
+ {"read_spec", "read_docs", "read_workflows", "read_results", "write_verdict"}
91
+ )
92
+ REVIEW_TOOLS = frozenset(
93
+ {"read_spec", "read_docs", "read_workflows", "read_results", "write_review"}
94
+ )
95
+ ANSWER_TOOLS = frozenset(
96
+ {"read_spec", "read_docs", "read_workflows", "write_workflow", "answer_review"}
97
+ )
98
+ REPORT_TOOLS = frozenset({"read_spec", "read_docs", "write_bug_report", "propose_spec_patch"})
99
+ MAX_REVIEWED_FINDINGS = 20
100
+ EXPECTABLE_LABELS = frozenset({"API_BUG", "SPEC_DRIFT", "CONFLICT"})
101
+ WRITER_TOOLS = frozenset(
102
+ {"read_spec", "read_workflows", "read_results", "read_tests", "write_test"}
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class TestConfig:
108
+ """Everything a `workflowval test` run needs."""
109
+
110
+ __test__ = False
111
+
112
+ spec_source: str
113
+ base_url: str
114
+ workdir: Path
115
+ models: Mapping[str, Model]
116
+ budget: Budget = field(default_factory=Budget)
117
+ docs_dir: Path | None = None
118
+ auth: AuthConfig | None = None
119
+ reports_dir: Path | None = None
120
+ read_only: bool = False
121
+ no_redact: bool = False
122
+ allowed_hosts: tuple[str, ...] = ()
123
+ plan_only: bool = False
124
+ verbose: bool = False
125
+ cost_tracked: bool = True
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class TestRunReport:
130
+ """What a run did."""
131
+
132
+ __test__ = False
133
+
134
+ run: RunResult
135
+ results: RunResults
136
+ results_path: Path
137
+ expectation_changes: list[str]
138
+ planned: bool
139
+ looks_fixed: list[str] = field(default_factory=list)
140
+ resumed: bool = False
141
+
142
+
143
+ def auth_env_names(auth: AuthConfig | None) -> list[str]:
144
+ """The env vars an auth config names."""
145
+ if auth is None:
146
+ return []
147
+ names = []
148
+ for key, value in auth.scheme.model_dump().items():
149
+ if key.endswith("_env"):
150
+ names += list(value.values()) if isinstance(value, dict) else [value]
151
+ return sorted(names)
152
+
153
+
154
+ def _needs_planning(config: TestConfig, current_hash: str) -> bool:
155
+ """Plan unless the same spec was fully planned already and workflow files exist."""
156
+ state_path = config.workdir / ".workflowval" / "state.json"
157
+ has_workflows = any((config.workdir / "workflows").glob("*.json"))
158
+ if not (state_path.is_file() and has_workflows):
159
+ return True
160
+ try:
161
+ previous = load_json(RunState, state_path)
162
+ except (ValidationError, OSError):
163
+ return True
164
+ return previous.spec_hash != current_hash or "plan" not in previous.completed_steps
165
+
166
+
167
+ def _write_test_if_changed(workflow: Workflow, graph: SpecGraph, workdir: Path) -> None:
168
+ """Render a workflow's test, keeping a test that's already up to date with its workflow."""
169
+ path = workdir / test_path(workflow.id)
170
+ if path.is_file() and path.read_text(encoding="utf-8").startswith(hash_line(workflow)):
171
+ return
172
+ atomic_write_text(path, generate(workflow, graph).source)
173
+
174
+
175
+ def _resumable(config: TestConfig, current_hash: str) -> RunState | None:
176
+ """Return the state of a run that was killed partway, on this same spec (FR-13).
177
+
178
+ A run that ended by itself has a stop reason, so it starts afresh.
179
+ """
180
+ path = config.workdir / ".workflowval" / "state.json"
181
+ try:
182
+ state = load_json(RunState, path) if path.is_file() else None
183
+ except (ValidationError, OSError):
184
+ return None
185
+ if (
186
+ state is None
187
+ or state.stop_reason is not None
188
+ or state.spec_hash != current_hash
189
+ or "plan" not in state.completed_steps
190
+ ):
191
+ return None
192
+ return state
193
+
194
+
195
+ def _is_unedited(workflow: Workflow, graph: SpecGraph, workdir: Path) -> bool:
196
+ """True when a workflow's test is exactly what code renders, so no fix would be lost."""
197
+ path = workdir / test_path(workflow.id)
198
+ return path.is_file() and path.read_text(encoding="utf-8") == generate(workflow, graph).source
199
+
200
+
201
+ def _known_issue_from(
202
+ workflow: Workflow, outcome: Outcome, verdict: StoredVerdict
203
+ ) -> KnownIssue | None:
204
+ """Pin a reviewed failure to its step and check. None when it isn't tied to a response."""
205
+ index = outcome.steps_run - 1
206
+ if not 0 <= index < len(workflow.steps) or not outcome.exchanges:
207
+ return None
208
+ seen = outcome.exchanges[-1].get("status")
209
+ if seen is None:
210
+ return None
211
+ wrong_status = seen != workflow.steps[index].expect.status
212
+ return KnownIssue.model_validate(
213
+ {
214
+ "label": verdict.label.value,
215
+ "confidence": verdict.confidence,
216
+ "reason": verdict.reason,
217
+ "step": index,
218
+ "check": "status" if wrong_status else "body",
219
+ "status": seen if wrong_status else None,
220
+ }
221
+ )
222
+
223
+
224
+ def _failure_text(outcome: Outcome) -> str:
225
+ return outcome.failing_assertion or f"the test ended with status {outcome.status}"
226
+
227
+
228
+ class _Session:
229
+ """State the pipeline's steps share within one run."""
230
+
231
+ def __init__(
232
+ self, config: TestConfig, graph: SpecGraph, base_url: str, checker: ResponseChecker
233
+ ) -> None:
234
+ self.config = config
235
+ self.graph = graph
236
+ self.checker = checker
237
+ self.findings: list[Finding] = []
238
+ self.base_url = base_url
239
+ self.workdir = config.workdir
240
+ self.outcomes: dict[str, Outcome] = {}
241
+ self.needs_planning = True
242
+ self.looks_fixed: list[str] = []
243
+ self.test_files: dict[str, str] = {}
244
+ scheme = config.auth.scheme if config.auth else None
245
+ self.login_operations = {scheme.op} if isinstance(scheme, LoginAuth) else set()
246
+ self.planner = make_role("planner", config.models["planner"], PLANNER_TOOLS)
247
+ self.reviewer = make_role("reviewer", config.models["reviewer"], REVIEWER_TOOLS)
248
+ self.reporter = make_role("reviewer", config.models["reviewer"], REPORT_TOOLS)
249
+ self.plan_reviewer = make_role("reviewer", config.models["reviewer"], REVIEW_TOOLS)
250
+ self.answerer = make_role("planner", config.models["planner"], ANSWER_TOOLS)
251
+ self.review_finished = False
252
+ self.filed: set[str] = set()
253
+ self.writer = make_role("writer", config.models["writer"], WRITER_TOOLS)
254
+
255
+ def plan_step(self, ctx: RunContext) -> None:
256
+ if not self.needs_planning:
257
+ return
258
+ if not plan(ctx, self.planner, self.graph, self.login_operations):
259
+ raise StopRun(StopReason.INVALID_OUTPUT, "the Planner wrote no workflows")
260
+
261
+ def _run_tests(self, workflow_ids: list[str] | None) -> list[Outcome]:
262
+ return run_tests(
263
+ self.workdir / "tests" / "wf",
264
+ base_url=self.base_url,
265
+ workflow_ids=workflow_ids,
266
+ env_names=auth_env_names(self.config.auth),
267
+ read_only=self.config.read_only,
268
+ allowed_hosts=self.config.allowed_hosts,
269
+ no_redact=self.config.no_redact,
270
+ )
271
+
272
+ def _save_progress(self) -> None:
273
+ """Keep what a resumed run needs, since a killed process loses it."""
274
+ atomic_write_text(
275
+ self.workdir / ".workflowval" / "progress.json",
276
+ json.dumps(
277
+ {
278
+ "outcomes": {key: vars(value) for key, value in self.outcomes.items()},
279
+ "test_files": self.test_files,
280
+ "filed": sorted(self.filed),
281
+ },
282
+ default=str,
283
+ ),
284
+ )
285
+
286
+ def load_progress(self) -> None:
287
+ """Pick up where a killed run stopped."""
288
+ path = self.workdir / ".workflowval" / "progress.json"
289
+ if path.is_file():
290
+ saved = json.loads(path.read_text(encoding="utf-8"))
291
+ self.outcomes = {key: Outcome(**value) for key, value in saved["outcomes"].items()}
292
+ self.test_files = saved["test_files"]
293
+ self.filed = set(saved["filed"])
294
+
295
+ def _review_log(self) -> ReviewLog:
296
+ path = self.workdir / "review.json"
297
+ return load_json(ReviewLog, path) if path.is_file() else ReviewLog()
298
+
299
+ def _review_round(self, ctx: RunContext, instruction: str, *, after_tests: bool) -> bool:
300
+ """Run one review round and get every item answered. True if it proposed anything."""
301
+ if ctx.state.review_rounds >= ctx.budget.max_review_rounds or ctx.tool_context is None:
302
+ return False
303
+ ctx.state.review_rounds += 1
304
+ rounds_before = len(self._review_log().rounds)
305
+ ctx.tool_context = replace(ctx.tool_context, new_workflows_only=after_tests)
306
+ try:
307
+ with contextlib.suppress(TaskLimitReached):
308
+ ctx.run_role(self.plan_reviewer, instruction)
309
+ log = self._review_log()
310
+ if len(log.rounds) == rounds_before or not log.rounds[-1].items():
311
+ return False
312
+ note = (
313
+ "Tests that have run can't change, so give each workflow a new id."
314
+ if after_tests
315
+ else ""
316
+ )
317
+ for _ in range(2): # A model can stop early, so it gets one more chance.
318
+ open_ids = set(self._review_log().unanswered())
319
+ if not open_ids:
320
+ break
321
+ items = [
322
+ item.model_dump(exclude_none=True)
323
+ for item in self._review_log().rounds[-1].items()
324
+ if item.id in open_ids
325
+ ]
326
+ with contextlib.suppress(TaskLimitReached):
327
+ ctx.run_role(
328
+ self.answerer,
329
+ render_prompt(
330
+ "task_answer_review", items=json.dumps(items, indent=1), note=note
331
+ ),
332
+ max_calls=plan_call_limit(self.graph),
333
+ )
334
+ self._reject_unanswered()
335
+ return True
336
+ finally:
337
+ ctx.tool_context = replace(ctx.tool_context, new_workflows_only=False)
338
+ ctx.checkpoint()
339
+
340
+ def _reject_unanswered(self) -> None:
341
+ """Nothing is dropped silently, so an item the Planner left open is recorded as such."""
342
+ log = self._review_log()
343
+ refusal = Answer(decision=Decision.REJECTED, reason="The Planner gave no answer.")
344
+ for review in log.rounds:
345
+ for item in review.items():
346
+ if item.answer is None:
347
+ item.answer = refusal
348
+ save_json(log, self.workdir / "review.json")
349
+
350
+ def review_plan(self, ctx: RunContext) -> None:
351
+ """Review the plan before any test runs, keeping a round for after them if possible."""
352
+ if not self.needs_planning:
353
+ return
354
+ rounds = ctx.budget.max_review_rounds
355
+ before_tests = rounds if self.config.plan_only else max(rounds - 1, min(rounds, 1))
356
+ for _ in range(before_tests):
357
+ instruction = render_prompt("task_review_plan")
358
+ if not self._review_round(ctx, instruction, after_tests=False):
359
+ return
360
+
361
+ def _how_far(self, workflow: Workflow) -> str:
362
+ outcome = self.outcomes.get(test_id(workflow.id))
363
+ if outcome is None:
364
+ return f"- {workflow.id}: {workflow.rule}. It hasn't run."
365
+ return (
366
+ f"- {workflow.id}: {workflow.rule}. It {outcome.status}, and ran "
367
+ f"{outcome.steps_run} of {len(workflow.steps)} steps."
368
+ )
369
+
370
+ def review_results(self, ctx: RunContext) -> None:
371
+ """Look for what the tests never reached, and add new tests. Existing ones don't change."""
372
+ if not self.needs_planning or self.review_finished:
373
+ return
374
+ workflows = load_workflows(self.workdir)
375
+ known = [
376
+ f"- {workflow.id} ({workflow.known_issue.label}): {workflow.known_issue.reason}"
377
+ for workflow in workflows
378
+ if workflow.known_issue is not None
379
+ ]
380
+ known += [
381
+ f"- {group.message}, on {', '.join(sorted(group.operations))}"
382
+ for group in group_mismatches(
383
+ {key: outcome.calls for key, outcome in self.outcomes.items()},
384
+ self.checker,
385
+ self.graph,
386
+ )
387
+ ]
388
+ instruction = render_prompt(
389
+ "task_review_results",
390
+ results="\n".join(self._how_far(workflow) for workflow in workflows),
391
+ known="\n".join(known) or "none",
392
+ )
393
+ if not self._review_round(ctx, instruction, after_tests=True):
394
+ self.review_finished = True
395
+
396
+ def pending_tests(self, ctx: RunContext) -> frozenset[str]:
397
+ """Tests with no final result yet: all of them at first, then only new ones."""
398
+ ids = {test_id(workflow.id) for workflow in load_workflows(self.workdir)}
399
+ return frozenset(ids - set(ctx.state.closed_tests))
400
+
401
+ def render_tests(self, ctx: RunContext) -> None:
402
+ atomic_write_text(
403
+ self.workdir / "tests" / "wf" / "conftest.py",
404
+ render_conftest(self.base_url, self.config.auth, self.graph),
405
+ )
406
+ for workflow in load_workflows(self.workdir):
407
+ try:
408
+ _write_test_if_changed(workflow, self.graph, self.workdir)
409
+ except GenerationError as exc:
410
+ raise StopRun(StopReason.INVALID_OUTPUT, f"{workflow.id}: {exc}") from exc
411
+
412
+ def execute(self, ctx: RunContext, ids: frozenset[str] | None) -> list[TestOutcome]:
413
+ if not is_reachable(self.base_url):
414
+ raise StopRun(StopReason.API_UNREACHABLE, f"no response from {self.base_url}")
415
+ wanted = None if ids is None else [i.rsplit("::test_", 1)[1] for i in ids]
416
+ outcomes = self._run_tests(wanted)
417
+ for outcome in outcomes:
418
+ self.outcomes[test_id(outcome.workflow_id)] = outcome
419
+ self._save_progress()
420
+ atomic_write_text(
421
+ self.workdir / ".workflowval" / "last_results.json",
422
+ json.dumps(
423
+ {
424
+ key: {name: item for name, item in vars(value).items() if name != "calls"}
425
+ for key, value in self.outcomes.items()
426
+ },
427
+ default=str,
428
+ ),
429
+ )
430
+ return [
431
+ TestOutcome(test_id(o.workflow_id), o.ok, "" if o.ok else _failure_text(o))
432
+ for o in outcomes
433
+ ]
434
+
435
+ @property
436
+ def credentials(self) -> str:
437
+ scheme = self.config.auth.scheme if self.config.auth else None
438
+ return scheme.type if scheme else "none, so requests carry no credentials"
439
+
440
+ def _hint(self, outcome: Outcome) -> str:
441
+ last = outcome.calls[-1] if outcome.calls else {}
442
+ status = last.get("status")
443
+ mismatches = [m.message for m in self.checker.check_call(last)] if last else []
444
+ return classify(status if isinstance(status, int) else None, mismatches)
445
+
446
+ def label(self, ctx: RunContext, failures: Sequence[TestOutcome]) -> Mapping[str, Label]:
447
+ hints = {
448
+ failure.test_id: self._hint(self.outcomes[failure.test_id])
449
+ for failure in failures
450
+ if failure.test_id in self.outcomes
451
+ }
452
+ return label_failures(ctx, self.reviewer, failures, self.credentials, hints)
453
+
454
+ def _save_evidence(self, evidence: Mapping[str, Mapping[str, object]]) -> None:
455
+ path = self.workdir / ".workflowval" / "evidence.json"
456
+ saved = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
457
+ atomic_write_text(path, json.dumps({**saved, **evidence}, default=str))
458
+
459
+ def _written_reports(self) -> dict[str, str]:
460
+ path = self.workdir / ".workflowval" / "reports.json"
461
+ written: dict[str, str] = (
462
+ json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
463
+ )
464
+ return written
465
+
466
+ def _ask_for_file(
467
+ self, ctx: RunContext, label: str, reason: str, target: str, tests: list[str], call: object
468
+ ) -> None:
469
+ if label == Label.SPEC_DRIFT.value:
470
+ request_patch(ctx, self.reporter, reason=reason, target=target, call=call) # type: ignore[arg-type]
471
+ elif label in EXPECTABLE_LABELS:
472
+ request_report(
473
+ ctx, self.reporter, label=label, reason=reason, test_id=target, tests=tests
474
+ )
475
+
476
+ def write_failure_files(self, ctx: RunContext) -> None:
477
+ """Have the Reviewer write one report or patch per root cause, not one per test."""
478
+ verdicts = read_verdicts(self.workdir)
479
+ groups: dict[tuple[object, ...], list[str]] = {}
480
+ for key, status in sorted(ctx.state.closed_tests.items()):
481
+ outcome = self.outcomes.get(key)
482
+ verdict = verdicts.get(key)
483
+ if status not in EXPECTABLE_LABELS or outcome is None or verdict is None:
484
+ continue
485
+ if key in self.filed:
486
+ continue
487
+ self.filed.add(key)
488
+ if not outcome.calls:
489
+ continue
490
+ last = outcome.calls[-1]
491
+ root = (status, last.get("method"), last.get("template"))
492
+ # One conflict is one report. Other labels can differ by status on the same operation.
493
+ groups.setdefault(
494
+ root if status == "CONFLICT" else (*root, last.get("status")), []
495
+ ).append(key)
496
+ for tests in groups.values():
497
+ first = tests[0]
498
+ outcome = self.outcomes[first]
499
+ verdict = verdicts[first]
500
+ self._save_evidence({first: {"calls": outcome.calls, "tests": tests}})
501
+ self._ask_for_file(
502
+ ctx, verdict.label.value, verdict.reason, first, tests, outcome.calls[-1]
503
+ )
504
+ written = self._written_reports().get(first)
505
+ if written is not None:
506
+ self.test_files.update(dict.fromkeys(tests, written))
507
+ self._save_progress()
508
+
509
+ def review_mismatches(self, ctx: RunContext) -> None:
510
+ """Have the Reviewer rule once on each distinct way responses broke the spec."""
511
+ calls = {key: outcome.calls for key, outcome in self.outcomes.items()}
512
+ groups = group_mismatches(calls, self.checker, self.graph)
513
+ self.findings = [_unreviewed_finding(group) for group in groups]
514
+ for index, group in enumerate(groups[:MAX_REVIEWED_FINDINGS]):
515
+ verdict = review_finding(ctx, self.reviewer, group, self.credentials)
516
+ if verdict is None:
517
+ continue
518
+ self._save_evidence(
519
+ {
520
+ group.finding_id: {
521
+ "calls": [group.example],
522
+ "keys": [group.key],
523
+ "tests": sorted(group.tests),
524
+ }
525
+ }
526
+ )
527
+ self._ask_for_file(
528
+ ctx,
529
+ verdict.label.value,
530
+ verdict.reason,
531
+ group.finding_id,
532
+ sorted(group.tests),
533
+ group.example,
534
+ )
535
+ written = self._written_reports().get(group.finding_id)
536
+ self.findings[index] = self.findings[index].model_copy(
537
+ update={
538
+ "label": verdict.label,
539
+ "confidence": Confidence(verdict.confidence),
540
+ "reason": verdict.reason,
541
+ "action": _action_for(verdict.label.value, written),
542
+ }
543
+ )
544
+
545
+ def execute_once(self, ctx: RunContext) -> None:
546
+ """Run every saved test once, for `drift`. Nothing is labeled or fixed."""
547
+ if not is_reachable(self.base_url):
548
+ raise StopRun(StopReason.API_UNREACHABLE, f"no response from {self.base_url}")
549
+ for outcome in self._run_tests(None):
550
+ key = test_id(outcome.workflow_id)
551
+ self.outcomes[key] = outcome
552
+ ctx.state.closed_tests[key] = "passed" if outcome.ok else Label.UNRESOLVED.value
553
+
554
+ def fix(self, ctx: RunContext, failure: TestOutcome) -> None:
555
+ verdict = read_verdicts(self.workdir)[failure.test_id]
556
+ workflow_id = failure.test_id.rsplit("::test_", 1)[1]
557
+ values = {"workflow_id": workflow_id, "reason": verdict.reason, "failure": failure.message}
558
+ if verdict.fix_target == "test":
559
+ ctx.run_role(self.writer, render_prompt("task_fix_test", **values), workflow_id)
560
+ return
561
+ ctx.run_role(self.planner, render_prompt("task_fix_workflow", **values), workflow_id)
562
+ for workflow in load_workflows(self.workdir):
563
+ if workflow.id == workflow_id:
564
+ try:
565
+ _write_test_if_changed(workflow, self.graph, self.workdir)
566
+ except GenerationError as exc:
567
+ raise StopRun(StopReason.INVALID_OUTPUT, f"{workflow_id}: {exc}") from exc
568
+
569
+ def _save_and_render(self, workflow: Workflow) -> None:
570
+ atomic_write_text(
571
+ self.workdir / "workflows" / f"{workflow.id}.json",
572
+ workflow.model_dump_json(indent=2, exclude_none=True) + "\n",
573
+ )
574
+ atomic_write_text(
575
+ self.workdir / test_path(workflow.id), generate(workflow, self.graph).source
576
+ )
577
+
578
+ def update_known_issues(self, ctx: RunContext) -> None:
579
+ """Mark real failures as expected, then confirm the mark with one run. No model involved.
580
+
581
+ A locked file is never edited, and a test the Writer fixed by hand is never regenerated, so
582
+ those failures simply stay failing.
583
+ """
584
+ verdicts = read_verdicts(self.workdir)
585
+ marked: list[str] = []
586
+ for workflow in load_workflows(self.workdir):
587
+ key = test_id(workflow.id)
588
+ outcome = self.outcomes.get(key)
589
+ verdict = verdicts.get(key)
590
+ if (
591
+ outcome is None
592
+ or outcome.passed
593
+ or outcome.expected
594
+ or verdict is None
595
+ or verdict.label.value not in EXPECTABLE_LABELS
596
+ or ctx.state.closed_tests.get(key) != verdict.label.value
597
+ or workflow.locked
598
+ or not _is_unedited(workflow, self.graph, self.workdir)
599
+ ):
600
+ continue
601
+ issue = _known_issue_from(workflow, outcome, verdict)
602
+ if issue is not None:
603
+ issue = issue.model_copy(update={"report": self.test_files.get(key)})
604
+ self._save_and_render(workflow.model_copy(update={"known_issue": issue}))
605
+ marked.append(workflow.id)
606
+ if marked:
607
+ for outcome in self._run_tests(marked):
608
+ self.outcomes[test_id(outcome.workflow_id)] = outcome
609
+ self._save_progress()
610
+ self._clear_fixed()
611
+
612
+ def _clear_fixed(self) -> None:
613
+ """Remove the mark from a test that now passes. The bug looks fixed."""
614
+ for workflow in load_workflows(self.workdir):
615
+ outcome = self.outcomes.get(test_id(workflow.id))
616
+ if (
617
+ workflow.known_issue is not None
618
+ and outcome is not None
619
+ and outcome.passed
620
+ and not workflow.locked
621
+ ):
622
+ self.looks_fixed.append(f"{workflow.id}: {workflow.known_issue.reason}")
623
+ self._save_and_render(workflow.model_copy(update={"known_issue": None}))
624
+
625
+
626
+ def _unreviewed_finding(group: MismatchGroup) -> Finding:
627
+ return Finding(
628
+ id=group.finding_id,
629
+ message=group.message,
630
+ operations=sorted(group.operations),
631
+ tests=sorted(group.tests),
632
+ )
633
+
634
+
635
+ def _action_for(label: str, path: str | None) -> Action:
636
+ if path is None:
637
+ return Action(kind=ActionKind.NONE)
638
+ kind = ActionKind.SPEC_PATCH if label == Label.SPEC_DRIFT.value else ActionKind.BUG_REPORT
639
+ return Action(kind=kind, path=path)
640
+
641
+
642
+ def _evidence_for(outcome: Outcome, graph: SpecGraph) -> Evidence:
643
+ last = outcome.calls[-1] if outcome.calls else {}
644
+ operation = graph.operation_at(str(last.get("method")), str(last.get("template")))
645
+ excerpt = (
646
+ operation.model_dump_json(by_alias=True, exclude_none=True)[:2000] if operation else None
647
+ )
648
+ return Evidence(
649
+ exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges], spec_excerpt=excerpt
650
+ )
651
+
652
+
653
+ def _final_verdict(
654
+ status: str,
655
+ stored: StoredVerdict | None,
656
+ outcome: Outcome,
657
+ issue: KnownIssue | None,
658
+ graph: SpecGraph,
659
+ report: str | None = None,
660
+ ) -> Verdict | None:
661
+ if outcome.expected and issue is not None:
662
+ return Verdict(
663
+ label=Label(issue.label),
664
+ confidence=Confidence(issue.confidence),
665
+ reason=issue.reason,
666
+ evidence=_evidence_for(outcome, graph),
667
+ action=_action_for(issue.label, issue.report),
668
+ )
669
+ if status == "passed" or outcome.passed:
670
+ return None
671
+ label = Label(status)
672
+ if stored is not None and stored.label is label:
673
+ confidence, reason = Confidence(stored.confidence), stored.reason
674
+ else:
675
+ confidence = Confidence.LOW
676
+ reason = f"not resolved: {_failure_text(outcome)}"
677
+ return Verdict(
678
+ label=label,
679
+ confidence=confidence,
680
+ reason=reason,
681
+ evidence=_evidence_for(outcome, graph),
682
+ action=_action_for(status, report),
683
+ )
684
+
685
+
686
+ def _build_results(session: _Session, run_result: RunResult) -> RunResults:
687
+ verdicts = read_verdicts(session.workdir)
688
+ workflows = {workflow.id: workflow for workflow in load_workflows(session.workdir)}
689
+ state = run_result.state
690
+ tests = []
691
+ for key, status in sorted(run_result.tests.items()):
692
+ workflow_id = key.rsplit("::test_", 1)[1]
693
+ outcome = session.outcomes.get(key) or Outcome(workflow_id, "error")
694
+ workflow = workflows.get(workflow_id)
695
+ if workflow is None:
696
+ continue
697
+ verdict = _final_verdict(
698
+ status,
699
+ verdicts.get(key),
700
+ outcome,
701
+ workflow.known_issue,
702
+ session.graph,
703
+ session.test_files.get(key),
704
+ )
705
+ tests.append(
706
+ TestResult(
707
+ id=key,
708
+ workflow_id=workflow_id,
709
+ basis=workflow.basis,
710
+ status=TestStatus(outcome.status),
711
+ duration_s=max(outcome.duration_s, 0.0),
712
+ failing_assertion=outcome.failing_assertion,
713
+ stdout=outcome.stdout,
714
+ exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges[-3:]],
715
+ fix_attempts=state.fix_attempts.get(key, 0),
716
+ verdict=verdict,
717
+ )
718
+ )
719
+ return RunResults(
720
+ uncovered_operations=uncovered_operations(
721
+ workflows.values(), session.graph, session.login_operations
722
+ ),
723
+ stop_reason=run_result.stop_reason.value,
724
+ stop_detail=run_result.detail or None,
725
+ tests=tests,
726
+ findings=session.findings,
727
+ reviews=session._review_log().rounds,
728
+ tasks_stopped=state.tasks_stopped,
729
+ input_tokens=state.input_tokens,
730
+ output_tokens=state.output_tokens,
731
+ cost_usd=state.cost_usd,
732
+ unpriced_calls=state.unpriced_calls,
733
+ )
734
+
735
+
736
+ def _read_expectation_changes(workdir: Path) -> list[str]:
737
+ path = workdir / ".workflowval" / "expectation_changes.jsonl"
738
+ if not path.is_file():
739
+ return []
740
+ lines = []
741
+ for line in path.read_text(encoding="utf-8").splitlines():
742
+ entry = json.loads(line)
743
+ lines += [f"{entry['workflow_id']}: {change}" for change in entry["changes"]]
744
+ return lines
745
+
746
+
747
+ def _reports_folder(config: TestConfig) -> Path:
748
+ return config.reports_dir or config.workdir / "reports"
749
+
750
+
751
+ def _tool_context(config: TestConfig, graph: SpecGraph, raw: dict[str, Any]) -> ToolContext:
752
+ return ToolContext(
753
+ config.workdir, graph, config.docs_dir, _reports_folder(config), raw, config.auth
754
+ )
755
+
756
+
757
+ def results_from_outcomes(outcomes: Sequence[Outcome], workdir: Path) -> RunResults:
758
+ """Build results from a model-free `run`. Only marks already reviewed carry a label."""
759
+ workflows = {workflow.id: workflow for workflow in load_workflows(workdir)}
760
+ tests = []
761
+ for outcome in outcomes:
762
+ workflow = workflows.get(outcome.workflow_id)
763
+ issue = workflow.known_issue if workflow else None
764
+ verdict = None
765
+ if outcome.expected and issue is not None:
766
+ verdict = Verdict(
767
+ label=Label(issue.label),
768
+ confidence=Confidence(issue.confidence),
769
+ reason=issue.reason,
770
+ action=_action_for(issue.label, issue.report),
771
+ )
772
+ tests.append(
773
+ TestResult(
774
+ id=test_id(outcome.workflow_id),
775
+ workflow_id=outcome.workflow_id,
776
+ basis=workflow.basis if workflow else Basis.ASSUMED,
777
+ status=TestStatus(outcome.status),
778
+ duration_s=max(outcome.duration_s, 0.0),
779
+ failing_assertion=outcome.failing_assertion,
780
+ stdout=outcome.stdout,
781
+ exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges[-3:]],
782
+ verdict=verdict,
783
+ )
784
+ )
785
+ return RunResults(stop_reason="completed", tests=tests)
786
+
787
+
788
+ def run_drift_pipeline(config: TestConfig) -> TestRunReport:
789
+ """Run `workflowval drift`: re-run saved tests and rule only on what breaks the spec."""
790
+ spec = load_spec(config.spec_source)
791
+ if not any((config.workdir / "workflows").glob("*.json")):
792
+ raise SpecError("no workflows here yet: run `workflowval test` first")
793
+ graph = build_graph(spec)
794
+ base_url = resolve_base_url(config.base_url, graph)
795
+ raw = load_raw_spec(config.spec_source)
796
+ session = _Session(config, graph, base_url, ResponseChecker(raw))
797
+ for name in ("verdicts.json", "evidence.json", "reports.json"):
798
+ (config.workdir / ".workflowval" / name).unlink(missing_ok=True)
799
+ # `drift` isn't a test run, so the next `test` must still know the plan is done.
800
+ state_path = config.workdir / ".workflowval" / "state.json"
801
+ earlier_state = state_path.read_bytes() if state_path.is_file() else None
802
+ result = run(
803
+ Pipeline(
804
+ (
805
+ CodeStep("render_tests", session.render_tests),
806
+ CodeStep("execute_once", session.execute_once),
807
+ CodeStep("review_mismatches", session.review_mismatches),
808
+ )
809
+ ),
810
+ config.budget,
811
+ workdir=config.workdir,
812
+ spec_hash=spec_hash(spec),
813
+ verbose=config.verbose,
814
+ tool_context=_tool_context(config, graph, raw),
815
+ cost_tracked=config.cost_tracked,
816
+ )
817
+ if earlier_state is not None:
818
+ state_path.write_bytes(earlier_state)
819
+ results = _build_results(session, result)
820
+ results_path = config.workdir / "results.json"
821
+ save_json(results, results_path)
822
+ write_reports(results, _reports_folder(config))
823
+ return TestRunReport(result, results, results_path, [], False)
824
+
825
+
826
+ def run_test_pipeline(config: TestConfig) -> TestRunReport:
827
+ """Run `workflowval test`. Spec problems raise SpecError before any model is called."""
828
+ spec = load_spec(config.spec_source)
829
+ graph = build_graph(spec)
830
+ current_hash = spec_hash(spec)
831
+ base_url = resolve_base_url(config.base_url, graph)
832
+ raw = load_raw_spec(config.spec_source)
833
+ session = _Session(config, graph, base_url, ResponseChecker(raw))
834
+ resume = None if config.plan_only else _resumable(config, current_hash)
835
+ session.needs_planning = resume is not None or _needs_planning(config, current_hash)
836
+ state_dir = config.workdir / ".workflowval"
837
+ if resume is not None:
838
+ session.load_progress()
839
+ else:
840
+ # Each run reports only its own expectation changes and verdicts.
841
+ for name in (
842
+ "expectation_changes.jsonl",
843
+ "verdicts.json",
844
+ "last_results.json",
845
+ "evidence.json",
846
+ "reports.json",
847
+ "progress.json",
848
+ ):
849
+ (state_dir / name).unlink(missing_ok=True)
850
+ if session.needs_planning and resume is None:
851
+ (config.workdir / "review.json").unlink(missing_ok=True)
852
+ steps: list[CodeStep | FixLoop] = [
853
+ CodeStep("plan", session.plan_step),
854
+ CodeStep("review_plan", session.review_plan),
855
+ ]
856
+ if not config.plan_only:
857
+ steps += [
858
+ CodeStep("render_tests", session.render_tests),
859
+ FixLoop(
860
+ "test_and_fix", session.execute, session.label, session.fix, session.pending_tests
861
+ ),
862
+ CodeStep("write_failure_files", session.write_failure_files),
863
+ CodeStep("update_known_issues", session.update_known_issues),
864
+ ]
865
+ # Rounds left after the first review each look for what the tests never reached.
866
+ for index in range(config.budget.max_review_rounds):
867
+ steps += [
868
+ CodeStep(f"review_results_{index}", session.review_results),
869
+ CodeStep(f"render_new_tests_{index}", session.render_tests),
870
+ FixLoop(
871
+ f"test_new_{index}",
872
+ session.execute,
873
+ session.label,
874
+ session.fix,
875
+ session.pending_tests,
876
+ ),
877
+ CodeStep(f"write_new_failure_files_{index}", session.write_failure_files),
878
+ CodeStep(f"update_new_known_issues_{index}", session.update_known_issues),
879
+ ]
880
+ steps += [
881
+ CodeStep("review_mismatches", session.review_mismatches),
882
+ ]
883
+ result = run(
884
+ Pipeline(tuple(steps)),
885
+ config.budget,
886
+ workdir=config.workdir,
887
+ spec_hash=current_hash,
888
+ verbose=config.verbose,
889
+ tool_context=_tool_context(config, graph, raw),
890
+ cost_tracked=config.cost_tracked,
891
+ resume=resume,
892
+ )
893
+ results = _build_results(session, result)
894
+ results_path = config.workdir / "results.json"
895
+ save_json(results, results_path)
896
+ if not config.plan_only:
897
+ write_reports(results, _reports_folder(config))
898
+ return TestRunReport(
899
+ result,
900
+ results,
901
+ results_path,
902
+ _read_expectation_changes(config.workdir),
903
+ session.needs_planning,
904
+ session.looks_fixed,
905
+ resume is not None,
906
+ )