Tasks: User Story 2 — Assert Per-Turn Ground Truths and Expected Tools (P1)¶
Input: /docs/product-specs/032-multi-turn-test-cases/ — spec.md (US2), data-model.md §8–§9, contracts/turn-result-schema.md §3–§4.
Approach: Test-Driven Development.
Story Goal: For each turn, run the configured metric stack against that turn's ground_truth and verify expected_tools: list[str] (legacy name-only form) were called in that turn. Roll up per-metric scores to the test-case level using score = mean(turn_scores) and passed = all(turn_passed), skipping turns that had no ground truth for a given metric. TurnResult.passed composes four conjuncts (errors, skipped, tools_matched, metrics) per contract §3.
Independent Test: Author a test case where turn 3 has ground_truth: "25587" and expected_tools: ["subtract"]. Run holodeck test. Verify turn 3 passes on metric match and tool-name match; fails if the agent answers "25000" or never calls subtract. Failure pinpoints turn index in the report (SC-004).
Depends on: US1 Phase 2 foundational models (Turn, TurnResult, TestResult.turns, MetricResult.kind='code' widening, TurnResult.grader_details) and US1 T002 fixture. If those aren't landed, start there first.
Phase 1: Setup¶
- T001 Re-read
docs/product-specs/032-multi-turn-test-cases/spec.md§US2 and data-model.md §7a (MetricResult.kind) + §9 (roll-up rules). Confirm US1 foundational models are merged.
Phase 2: Per-turn ground-truth evaluation (TDD)¶
Tests first¶
- T002 [P] [US2] Write
tests/unit/lib/test_runner/test_executor_per_turn_metrics.py::test_turn_with_ground_truth_runs_metrics— a turn withground_truthand a BLEU-configured agent produces aMetricResult(metric_name="bleu")on thatTurnResult.metric_results, withpassedderived from the configured threshold. - T003 [P] [US2] Add
test_executor_per_turn_metrics.py::test_turn_without_ground_truth_skips_text_metrics— a turn with noground_truthhas no BLEU/ROUGE/METEOR entries in itsmetric_results(A6). - T004 [P] [US2] Add
test_executor_per_turn_metrics.py::test_per_turn_evaluations_override_agent_defaults— whenturns[i].evaluationsis set, it overrides agent-level metrics for that turn only (FR-023). - T004a [P] [US2] Add
test_executor_per_turn_metrics.py::test_test_case_evaluations_override_agent_when_turn_unset— agent has BLEU only, test_case has ROUGE only, turn has noevaluations→ turn evaluates ROUGE only. Covers the middle rung of the three-level resolver. - T005 [P] [US2] Add
test_executor_per_turn_metrics.py::test_rollup_mean_across_turns— agent has BLEU configured; 3 turns score 0.8, 0.4, 0.6 →TestResult.metric_results[bleu].score == 0.6andpassed = all(per-turn passed). - T006 [P] [US2] Add
test_executor_per_turn_metrics.py::test_rollup_skips_turns_without_metric— turn 2 has noground_truth; the metric averages only over turns 1 and 3 (data-model.md §9). - T007 [P] [US2] Add
test_executor_per_turn_metrics.py::test_metric_omitted_when_every_turn_skipped— no turn has aground_truth; the metric does not appear inTestResult.metric_resultsat all. - T008 [P] [US2] Add
test_executor_per_turn_metrics.py::test_failing_turn_flips_rolled_up_passed— 2/3 turns pass BLEU, 1 fails → rolled-uppassed=Falseeven though mean score might still exceed threshold.
Implementation¶
- T009a [US2] Refactor
_run_evaluationsinsrc/holodeck/lib/test_runner/executor.py(lines 772-910) to accept(agent_response, ground_truth, retrieval_context, processed_files, tool_results)as parameters instead of reading fromtest_case.input/test_case.ground_truth. Single-turn legacy call site atexecutor.py:724-740must pass equivalent values — verified by the existing single-turn test suite staying green (SC-002). - T009b [US2] Add
_resolve_turn_metrics(agent_config, test_case, turn) -> list[MetricType]inexecutor.pyreturning per-turn effective metrics with precedence turn > test_case > agent. Extends the existing two-level_get_metrics_for_test(executor.py:912-937). Unit-tested via T004/T004a. - T009c [US2] In
_run_multi_turn, after eachsession.send(), call_resolve_turn_metrics(...)then_run_evaluations(...)with the per-turn inputs. Store the resultingMetricResultlist onTurnResult.metric_results. - T009d [US2] Run
pytest tests/unit/lib/test_runner -n auto— confirm no legacy regressions after the T009a refactor (SC-002 gate before proceeding to Phase 3). - T010 [US2] Implement
_rollup_metric_results(turns: list[TurnResult]) -> list[MetricResult]per data-model.md §9: group bymetric_name, skip turns where the metric is absent, computescore = mean(turn_scores),passed = all(turn_passed), drop metrics that every turn skipped. - T011 [US2] Wire
_rollup_metric_resultsinto_finalize_multi_turn_resultsoTestResult.metric_resultsis populated from the per-turn data (replacing the empty-list stub left by US1 T032). - T012 [US2] Implement
TurnResult.passedper contracts/turn-result-schema.md §3:errors == [] and skipped is False and tools_matched is not False and all(m.passed is not False for m in metric_results). All four conjuncts required. - T012a [P] [US2] Add
test_executor_per_turn_metrics.py::test_turn_with_errors_only_fails— a turn witherrors=['timeout'], noexpected_tools, noground_truth, nometric_resultshaspassed=False. Locks in the first conjunct (errors == []). Alsotest_skipped_turn_failsandtest_tools_matched_false_failsfor completeness. - T013 [US2] Run
pytest tests/unit/lib/test_runner/test_executor_per_turn_metrics.py -n auto— T002–T008 + T012a green.
Phase 3: Per-turn tool-name assertion (TDD)¶
Tests first¶
- T014 [P] [US2] Write
tests/unit/lib/test_runner/test_executor_per_turn_tools.py::test_tool_name_match_substring_scoped_to_turn— turn 3 assertsexpected_tools: ["subtract"]; a prior turn also calledsubtract. Only in-turn calls count → turn 3 fails if it never calledsubtractthat turn (spec Scenario US2.3). - T015 [P] [US2] Add
test_executor_per_turn_tools.py::test_per_turn_tool_match_inherits_substring_contract— regression guard thatvalidate_tool_callssubstring semantics (case-sensitive,expected in actual) still hold when called from the multi-turn path. Covers SK plugin-name prefixing ("Math-subtract"accepts expected"subtract"). - T016 [P] [US2] Add
test_executor_per_turn_tools.py::test_missing_expected_tool_fails_turn— expected["divide"], agent calls nothing →tools_matched=False,turn.passed=False,errorsmentions the missing name. - T017 [P] [US2] Add
test_executor_per_turn_tools.py::test_extra_tools_do_not_fail_turn— expected["subtract"], agent also callslookup→tools_matched=True(FR —expected_toolsis a lower bound, not upper). - T018 [P] [US2] Add
test_executor_per_turn_tools.py::test_no_expected_tools_leaves_matched_none— turn with noexpected_tools→TurnResult.tools_matched is None; the test-case rollup treatsNoneas neutral. - T019 [P] [US2] Add
test_executor_per_turn_tools.py::test_rollup_tools_matched_all_turns— covers three cases: (a) turn 1matched=True, turn 2matched=None, turn 3matched=True→ test-caseTrue; (b) all turnsNone→ test-caseNone; (c) any turn explicitlyFalse→ test-caseFalse.
Implementation¶
- T020 [US2] Call
validate_tool_calls([inv.name for inv in turn.tool_invocations], turn.expected_tools)fromsrc/holodeck/lib/test_runner/executor.py. Do NOT modifyvalidate_tool_callsitself — its contract is shared with the legacy single-turn path and covered by existing tests (SC-002). SetTurnResult.tools_matchedper contracts/turn-result-schema.md §2. - T021 [US2] Populate
TurnResult.errorswith a human-readable missing-tool message whentools_matched=False. Preserve the existing legacy error-message shape for back-compat on single-turn cases. - T022 [US2] Update
_finalize_multi_turn_resultsoTestResult.tools_matchedaggregates per contracts/turn-result-schema.md §4:Noneif every turn isNone,Trueif all turns areTrue/None,Falseif any turn isFalse.
Phase 4: Reporter updates for per-turn metric/tool detail¶
Tests first¶
- T023 [P] [US2] Add
tests/unit/lib/test_runner/test_reporter_multi_turn.py::test_markdown_shows_per_turn_metric_scores— rendered markdown includes each turn's metric scores (e.g.,bleu=0.8) and tool match glyph. - T024 [P] [US2] Add
test_reporter_multi_turn.py::test_failure_pinpoints_turn_index— a failing turn's rendered line namesturn 2and the failing metric or missing tool (SC-004).
Implementation¶
- T025 [US2] Extend the US1 markdown hierarchy (from US1 T039) in
src/holodeck/lib/test_runner/reporter.pyto print per-turn metric scores and tool-match status under each turn row. JSON path stays automatic.
Phase 5: End-to-end happy path¶
- T026 [US2] Add
tests/integration/test_multi_turn_us2_e2e.py::test_4turn_per_turn_ground_truth_tools— 4-turn fixture (usetests/fixtures/multi_turn/convfinqa_sample.yamlcreated in US1 T002, extended here with ground truths +["subtract"]/["divide"]), stubbed backend with scripted responses and tool calls, asserts per-turn metric results appear, rolled-upTestResult.passed=True, per-turn breakdown in markdown report. - T027 [US2] Run
make format && make lint && pytest -n auto. All green.
Checkpoint: US1 + US2 together deliver a shippable P1 slice — multi-turn execution with per-turn success criteria.
Dependencies¶
- Phase 1 → Phase 2 → Phase 3 (parallel with Phase 2 implementation is OK, both touch
executor.pybut different helpers) → Phase 4 → Phase 5. - Within Phase 2: T002–T008, T012a parallel; T009a–T013 serial (all
executor.py). - Within Phase 3: T014–T019 parallel; T020–T022 serial.
Independent test criteria (recap)¶
- Per-turn
ground_truthmismatch fails that turn → whole test case fails (FR-016). - Turn tool assertion is scoped to its own turn (FR-011 — no cross-turn credit).
- Roll-up
score = meanandpassed = allmatches data-model.md §9 — verified by T005–T008. TurnResult.passed4-conjunct rule — verified by T012a.- Back-compat: every legacy single-turn test stays byte-identical (piggy-backs on US1 T051, gated here at T009d).