fix(subagents): preserve test summaries before evidence truncation (#7407)

* fix(subagents): preserve test summaries before evidence truncation

* fix(subagents): honor Node counters and cache harvested summaries

* fix(subagents): retain warning counts and Go case evidence

* fix(subagents): retain long pytest sibling summaries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Xuehao Xu 2026-10-25 10:25:04 +08:00 committed by GitHub
parent 3113c150cd
commit 8237bc6c9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 275 additions and 34 deletions

View File

@ -2568,7 +2568,13 @@ For `tests_passed:node --test`, complete TAP or spec counter summaries must
contain an actual pass and no failed or cancelled tests. Individual TAP `ok`
points do not prove the run passed. Empty, all-skipped, all-todo, incomplete or
inconsistent summaries remain `UNVERIFIED`; execution and shell-provenance
checks still apply.
checks still apply. Summary detection uses the complete recorded tool output
before retaining a bounded diagnostic tail, so later TODO stacks or warnings
do not erase the test evidence. Complete, consistent Node counters take
precedence over incidental log text such as `12 passed` or `2 errors`;
independently shaped summaries from other runners still contribute evidence.
Pytest sibling summaries retain warning counts and the optional human-readable
duration suffix printed for runs lasting at least a minute, such as `(0:01:05)`.
For Maven and Gradle test acceptance, a `BUILD SUCCESS` / `BUILD SUCCESSFUL`
banner alone remains `UNVERIFIED`: builds can succeed without running tests.

File diff suppressed because one or more lines are too long

View File

@ -41,7 +41,8 @@ Leaf families:
anchor to a *specific recorded execution* — a matching bash execution
(harvested by the executor from the same stamped ``ToolMessage``s the
receipt layer reads) with ``status=success`` and a test-summary shape in
its output tail — not merely to some successful call. Matching accounts
its recorded output (classified before tail truncation) — not merely to
some successful call. Matching accounts
for shell command structure (operator-separated segments, executables,
arguments), so an unrelated command that merely mentions the criterion
string (e.g. inside an ``echo`` argument or a comment) cannot anchor the
@ -75,7 +76,7 @@ import stat
import time
import unicodedata
from collections.abc import Callable, Mapping
from typing import Any, TypedDict
from typing import Any, Literal, TypedDict
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.subagents.report_contract import MAX_ACCEPTANCE_CRITERIA, normalize_acceptance_criteria
@ -163,17 +164,19 @@ _NODE_TEST_SUMMARY_RE = re.compile(
)
def _node_test_summary_holds(text: str) -> bool | None:
def _node_test_summary_outcome(text: str) -> Literal["passed", "failed", "unverified"] | None:
"""Use complete, consistent counts; failures/cancellations win."""
positive = False
complete = False
for match in _NODE_TEST_SUMMARY_RE.finditer(text):
total, passed, failed, cancelled, skipped, todo = (int(match[key]) for key in ("total", "passed", "failed", "cancelled", "skipped", "todo"))
if total != passed + failed + cancelled + skipped + todo:
continue
complete = True
if failed or cancelled:
return False
return "failed"
positive = positive or passed > 0
return True if positive else None
return "passed" if positive else "unverified" if complete else None
#: Zero-test evidence vetoes the pass shapes the count-bearing alternatives
@ -2026,6 +2029,49 @@ def _output_attribution(executed_command: str, thread_data: Mapping[str, Any] |
return None
# With complete Node counters present, only independently shaped sibling runner
# summaries may contribute evidence. Bare "N passed/errors" in program logs
# cannot override those counters. Keep the legacy loose fallback without Node.
_SIBLING_TEST_SUMMARY_RE = re.compile(
r"^[ \t=]*(?:[0-9]+ (?:passed|failed|errors?|warnings?|skipped|deselected|xfailed|xpassed)(?:, )?)+ in [0-9]+(?:\.[0-9]+)?s"
r"(?: \((?:[0-9]+ days?, )?[0-9]+:[0-5][0-9]:[0-5][0-9]\))?[ \t=]*\r?$"
r"|^test result: (?:ok|FAILED)\.[^\r\n]*$"
r"|^(?:ok|FAIL)[ \t]+[^\r\n]+$"
r"|^[ \t]*--- (?:PASS|FAIL|SKIP):[^\r\n]*$"
r"|^Ran [0-9]+ tests? in [0-9]+(?:\.[0-9]+)?s\r?\n\r?\n(?:OK|FAILED)[^\r\n]*$",
re.MULTILINE,
)
def summarize_test_output(output: str) -> Literal["passed", "failed", "unverified"]:
"""Classify the complete recorded tool output before evidence is truncated.
This bounded stamp carries summary semantics without retaining full logs.
It is execution evidence, not an acceptance verdict: consumers must still
enforce command matching, exit status, output attribution and shell provenance.
"""
# Test names are arbitrary data, not runner summaries. Exclude TAP/spec
# point labels from negative/zero evidence too (`test('1 failed', ...)`).
text = _NODE_TEST_POINT_RE.sub("", _ANSI_SGR_RE.sub("", output))
build_summary = _build_test_summary_holds(text)
node_summary = _node_test_summary_outcome(text)
if node_summary is not None:
# A complete Node report owns its family. Recognizable sibling reports
# still participate (for make/test scripts running several frameworks).
# Retain pytest warnings/durations and Go per-case markers too: these
# are part of the sibling evidence, not disposable diagnostic text.
text = "\n".join(match[0] for pattern in (_SIBLING_TEST_SUMMARY_RE, _MAVEN_TEST_SUMMARY_RE, _GRADLE_TEST_SUMMARY_RE) for match in pattern.finditer(text))
if _TEST_FAIL_SHAPE_RE.search(text) or build_summary is False or node_summary == "failed":
return "failed"
# Empty packages/targets cannot veto complete positive sibling evidence.
summary_text = _without_empty_cargo_targets(_GO_ZERO_TEST_LINE_RE.sub("", text))
go_ok = _GO_TEST_PASS_SHAPE_RE.search(summary_text) and _go_package_summary_is_evidence(summary_text)
passing = _TEST_PASS_SHAPE_RE.search(summary_text) or go_ok or _unittest_test_summary_holds(summary_text) or build_summary is True or node_summary == "passed"
if passing and not _TEST_ZERO_SHAPE_RE.search(summary_text):
return "passed"
return "unverified"
def _check_tests_passed_leaf(command: str, bash_executions: list[dict[str, Any]] | None, thread_data: Mapping[str, Any] | None = None) -> AcceptanceLeaf:
base: AcceptanceLeaf = {"criterion": "", "family": "tests_passed", "checked": False, "holds": False, "detail": ""}
matches: list[tuple[str, dict[str, Any]]] = []
@ -2083,26 +2129,21 @@ def _check_tests_passed_leaf(command: str, bash_executions: list[dict[str, Any]]
else:
base["detail"] = f"latest matching run recorded status={status or 'unknown'}"
return base
output_tail = _ANSI_SGR_RE.sub("", str(latest.get("output_tail") or ""))
unattributable = _output_attribution(str(latest.get("command") or ""), thread_data)
if unattributable is not None:
# Neither a pass nor a fail shape here can be trusted either way.
base["detail"] = unattributable
return base
# Test names are arbitrary data, not runner summaries. Exclude TAP/spec
# point labels from negative/zero evidence too (`test('1 failed', ...)`).
output_tail = _NODE_TEST_POINT_RE.sub("", output_tail)
build_summary = _build_test_summary_holds(output_tail)
node_summary = _node_test_summary_holds(output_tail)
if _TEST_FAIL_SHAPE_RE.search(output_tail) or build_summary is False or node_summary is False:
# New harvests classify the full ToolMessage before truncating its tail.
# Legacy entries have only the tail; retain their existing conservative check.
summary = latest.get("test_summary")
if summary not in ("passed", "failed", "unverified"):
summary = summarize_test_output(str(latest.get("output_tail") or ""))
if summary == "failed":
base["checked"] = True
base["detail"] = "recorded output carries a failing test summary"
return base
# Empty packages/targets cannot veto complete positive sibling evidence.
summary_tail = _without_empty_cargo_targets(_GO_ZERO_TEST_LINE_RE.sub("", output_tail))
go_ok = _GO_TEST_PASS_SHAPE_RE.search(summary_tail) and _go_package_summary_is_evidence(summary_tail)
passing = _TEST_PASS_SHAPE_RE.search(summary_tail) or go_ok or _unittest_test_summary_holds(summary_tail) or build_summary is True or node_summary is True
if passing and not _TEST_ZERO_SHAPE_RE.search(summary_tail):
if summary == "passed":
base["checked"] = True
base["holds"] = True
base["detail"] = "recorded output carries a passing test summary"

View File

@ -473,7 +473,7 @@ def _harvest_tool_receipts(
#: Bash-family tool names whose calls count as recorded command executions.
_BASH_EVIDENCE_TOOL_NAMES = BASH_TOOL_NAMES
#: Bounds for the harvested evidence: only the newest few executions travel,
#: with command/output text capped (test summaries print at the tail).
#: with command/output text capped and full-output summary semantics preserved.
_BASH_EVIDENCE_MAX_ENTRIES = 20
_BASH_EVIDENCE_COMMAND_CHARS = 500
_BASH_EVIDENCE_OUTPUT_TAIL_CHARS = 1000
@ -529,14 +529,16 @@ def _harvest_shell_persistence(final_state: Any) -> bool | None:
def _harvest_bash_executions(
final_state: Any,
*,
summary_cache: dict[str, tuple[str, str]] | None = None,
) -> list[dict[str, Any]] | None:
"""Harvest bounded bash command/output evidence from one streamed state.
RFC #4651 PR4: a ``tests_passed:<command>`` acceptance leaf must anchor to
a specific recorded execution — the command text lets the parent match the
criterion against the call that actually ran, and the bounded output tail
carries the test-summary shape. The recorded status is the **actual shell
exit status**: a nonzero bash exit comes back as ordinary output text
criterion against the call that actually ran. A bounded summary stamp is
computed from the full tool output before retaining the diagnostic tail.
The recorded status is the **actual shell exit status**: a nonzero bash exit comes back as ordinary output text
(local: a trailing ``Exit Code: N``; e2b/opensandbox with empty output:
``Command exited with code N``), which ``deerflow_tool_meta`` still reports
as success — so an explicit exit marker wins, and the meta status is only
@ -549,12 +551,17 @@ def _harvest_bash_executions(
UNVERIFIED.
"""
if not final_state:
if summary_cache is not None:
summary_cache.clear()
return None
messages = final_state.get("messages") if isinstance(final_state, dict) else None
if not messages:
if summary_cache is not None:
summary_cache.clear()
return None
try:
from deerflow.agents.middlewares.tool_result_meta import TOOL_META_KEY
from deerflow.subagents.acceptance_checks import summarize_test_output
commands: dict[str, tuple[str, str, bool]] = {}
for message in messages:
@ -573,20 +580,28 @@ def _harvest_bash_executions(
# selection-changing suffix like ``-k smoke`` could be cut).
commands[tool_call_id] = (name, command[:_BASH_EVIDENCE_COMMAND_CHARS], len(command) > _BASH_EVIDENCE_COMMAND_CHARS)
if not commands:
if summary_cache is not None:
summary_cache.clear()
return []
executions: list[dict[str, Any]] = []
for message in messages:
if not isinstance(message, ToolMessage):
continue
# Only retained executions need full-output parsing. The per-run cache
# survives values chunks, but keeps at most the same newest 20 outputs.
tool_messages = [message for message in messages if isinstance(message, ToolMessage) and str(message.tool_call_id or "") in commands][-_BASH_EVIDENCE_MAX_ENTRIES:]
next_cache: dict[str, tuple[str, str]] = {}
for message in tool_messages:
tool_call_id = str(message.tool_call_id or "")
entry = commands.get(tool_call_id)
if entry is None:
continue
entry = commands[tool_call_id]
name, command, command_truncated = entry
meta = (message.additional_kwargs or {}).get(TOOL_META_KEY) or {}
meta_status = str(meta.get("status") or getattr(message, "status", "success") or "success")
content = message.content if isinstance(message.content, str) else json.dumps(message.content, sort_keys=True, default=str)
status, status_marker = _bash_evidence_status(content, meta_status)
cached = summary_cache.get(tool_call_id) if summary_cache is not None else None
if cached is not None and (cached[0] is content or cached[0] == content):
test_summary = cached[1]
else:
test_summary = summarize_test_output(content)
next_cache[tool_call_id] = (content, test_summary)
output_tail = content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS:]
if len(content) > _BASH_EVIDENCE_OUTPUT_TAIL_CHARS and content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS - 1] != "\n":
# A clipped test name/log line can look like a summary after
@ -599,10 +614,14 @@ def _harvest_bash_executions(
"command": command,
"command_truncated": command_truncated,
"output_tail": output_tail,
"test_summary": test_summary,
"status": status,
"status_marker": status_marker,
}
)
if summary_cache is not None:
summary_cache.clear()
summary_cache.update(next_cache)
# Provenance stamp: whether the producing sandbox reuses one
# persistent shell session. Captured here — while the state that
# carried the evidence is at hand — because the parent-side checker
@ -1716,6 +1735,8 @@ class SubagentExecutor:
return None
return _harvest_tool_receipts(final_state, prefer_citing_turn=prefer_citing_turn)
bash_summary_cache: dict[str, tuple[str, str]] = {}
def current_bash_executions() -> list[dict[str, Any]] | None:
# RFC #4651 PR4: evidence for tests_passed acceptance leaves.
# Accumulated from every chunk (not harvested once at terminal) so
@ -1723,7 +1744,7 @@ class SubagentExecutor:
# execution. Criteria-free runs pay nothing.
if not self.acceptance_criteria:
return None
return _harvest_bash_executions(final_state)
return _harvest_bash_executions(final_state, summary_cache=bash_summary_cache)
try:
if task_info is not None and task_store is not None:

View File

@ -10,6 +10,7 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
from _pytest.terminal import format_session_duration
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import StructuredTool
from langgraph.graph import END, START, MessagesState, StateGraph
@ -172,6 +173,31 @@ def test_harvest_drops_a_partial_label_before_interpreting_counts(executor_modul
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (False, False)
@pytest.mark.parametrize(
"output,expected",
[
(_summary("ℹ", todo=1) + "\n" + " at fixture\n" * 150, (True, True)),
(_summary("ℹ", failed=1) + "\n" + " at fixture\n" * 150, (True, False)),
(_summary("ℹ", passed=0, todo=1) + "\n" + " at fixture\n" * 150, (False, False)),
(_summary("ℹ", failed=1) + "\n" + "diagnostic\n" * 150 + _summary("ℹ"), (True, False)),
("0 passed\n" + "diagnostic\n" * 150 + "2 passed", (False, False)),
],
ids=["pass-before-stack", "failure-before-stack", "all-todo-before-stack", "failure-before-passing-tail", "zero-before-passing-tail"],
)
def test_full_summary_survives_bounded_harvest(executor_module, monkeypatch, output, expected):
messages = [AIMessage(content="", tool_calls=[{"name": "bash", "id": "node-call", "args": {"command": "node --test"}}]), ToolMessage(content=output, tool_call_id="node-call")]
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
executions = executor_module._harvest_bash_executions({"messages": messages})
assert len(executions[0]["output_tail"]) <= 1000
verdict = check_acceptance_criteria(["tests_passed:node --test"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected
@pytest.mark.parametrize("overrides", [{"shell_persistent": True}, {"shell_persistent": None}, {"command_truncated": True}, {"command": "echo other; node --test"}, {"command": "node --test > log"}])
def test_full_summary_does_not_bypass_admission(overrides):
assert _verdict("", test_summary="passed", **overrides)["leaves"][0]["checked"] is False
@pytest.mark.skipif(os.name == "nt" or shutil.which("node") is None, reason="Requires POSIX LocalSandbox and Node.js")
@pytest.mark.asyncio
@pytest.mark.parametrize("reporter", ["tap", "spec", "color-spec"])
@ -194,6 +220,8 @@ def test_harvest_drops_a_partial_label_before_interpreting_counts(executor_modul
("describe('1 failed', () => { test('sample', () => {}); });", (True, True)),
("test('sample', () => { throw Error('fixture'); });", (True, False)),
("test('pending', { timeout: 20 }, () => new Promise(() => {}));", (True, False)),
("console.log('12 passed'); for (let i=0; i<250; i++) test.skip('t'+i,()=>{});", (False, False)),
("console.log('validation found 2 errors'); for (let i=0; i<250; i++) test('t'+i,()=>{});", (True, True)),
],
ids=[
"pass",
@ -212,12 +240,12 @@ def test_harvest_drops_a_partial_label_before_interpreting_counts(executor_modul
"summary-like-suite",
"failure",
"cancelled",
"stray-pass-all-skipped",
"stray-error-all-passing",
],
)
async def test_real_node_results_reach_parent_acceptance(tmp_path, monkeypatch, executor_module, reporter, body, expected):
# Keep reporter counts inside the bounded evidence tail regardless of
# temporary-path length; truncation itself has dedicated coverage above.
(tmp_path / "sample.test.cjs").write_text("const { test, describe } = require('node:test');\nError.stackTraceLimit = 0;\n" + body, encoding="utf-8")
(tmp_path / "sample.test.cjs").write_text("const { test, describe } = require('node:test');\n" + body, encoding="utf-8")
monkeypatch.delenv("NO_COLOR", raising=False)
sandbox = LocalSandbox("node-evidence", path_mappings=[PathMapping("/mnt/user-data/workspace", str(tmp_path))])
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda sandbox_id: sandbox if sandbox_id == sandbox.id else None))
@ -242,3 +270,138 @@ async def test_real_node_results_reach_parent_acceptance(tmp_path, monkeypatch,
assert len(executions) == 1 and executions[0]["shell_persistent"] is False
verdict = check_acceptance_criteria([f"tests_passed:{command}"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected, (outputs, verdict)
@pytest.mark.skipif(os.name == "nt" or shutil.which("node") is None, reason="Requires POSIX LocalSandbox and Node.js")
def test_real_node_todo_stack_after_summary_survives_harvest(tmp_path, monkeypatch, executor_module):
workspace = tmp_path / ("long-path-" * 15)
workspace.mkdir()
test_file = workspace / "sample.test.cjs"
test_file.write_text(
"const {test}=require('node:test');\ntest('sample',()=>{});\ntest('1 failed',{todo:true},()=>{throw Error('fixture');});\n",
encoding="utf-8",
)
command = f"NO_COLOR=1 FORCE_COLOR=1 {shlex.quote(shutil.which('node'))} --test --test-reporter=spec {shlex.quote(str(test_file))}"
sandbox = LocalSandbox("node-long-todo-evidence")
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda sandbox_id: sandbox if sandbox_id == sandbox.id else None))
output = sandbox.execute_command(command, timeout=30)
state = {
"sandbox": {"sandbox_id": sandbox.id},
"messages": [AIMessage(content="", tool_calls=[{"name": "bash", "id": "node-call", "args": {"command": command}}]), ToolMessage(content=output, tool_call_id="node-call")],
}
executions = executor_module._harvest_bash_executions(state)
assert len(output) > 1000
assert len(executions[0]["output_tail"]) <= 1000
assert executions[0]["test_summary"] == "passed"
verdict = check_acceptance_criteria([f"tests_passed:{command}"], bash_executions=executions)
assert verdict["all_hold"] is True, (output, executions, verdict)
@pytest.mark.parametrize("prefix", ["#", "ℹ"])
@pytest.mark.parametrize("log", ["12 passed", "all tests passed", "validation found 2 errors", "1 failed"])
@pytest.mark.parametrize("passed,skipped,expected", [(0, 250, (False, False)), (250, 0, (True, True))])
def test_node_counts_outrank_incidental_logs(executor_module, monkeypatch, prefix, log, passed, skipped, expected):
output = (f"# {log}" if prefix == "#" else log) + "\n" + "diagnostic\n" * 250 + _summary(prefix, passed=passed, skipped=skipped)
messages = [AIMessage(content="", tool_calls=[{"name": "bash", "id": "node-call", "args": {"command": "node --test"}}]), ToolMessage(content=output, tool_call_id="node-call")]
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
executions = executor_module._harvest_bash_executions({"messages": messages})
verdict = check_acceptance_criteria(["tests_passed:node --test"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected
@pytest.mark.parametrize(
"sibling,expected", [("2 passed in 0.01s", (True, True)), ("1 failed in 0.01s", (True, False)), ("Tests run: 2, Failures: 0, Errors: 0, Skipped: 0", (True, True)), ("Tests run: 2, Failures: 1, Errors: 0, Skipped: 0", (True, False))]
)
def test_node_counts_preserve_other_runner_summaries(sibling, expected):
verdict = _verdict(_summary(passed=0, skipped=250) + "\n" + sibling)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected
def test_harvest_reuses_unchanged_summaries_and_invalidates_changes(executor_module, monkeypatch):
from unittest.mock import Mock
from deerflow.subagents import acceptance_checks
classify = Mock(wraps=acceptance_checks.summarize_test_output)
monkeypatch.setattr(acceptance_checks, "summarize_test_output", classify)
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
call = AIMessage(content="", tool_calls=[{"name": "bash", "id": "node-call", "args": {"command": "node --test"}}])
tool = ToolMessage(content=_summary(), tool_call_id="node-call")
cache = {}
for _ in range(5):
entries = executor_module._harvest_bash_executions({"messages": [call, tool]}, summary_cache=cache)
assert entries[0]["test_summary"] == "passed"
assert classify.call_count == 1
tool.content = _summary(failed=1)
entries = executor_module._harvest_bash_executions({"messages": [call, tool]}, summary_cache=cache)
assert entries[0]["test_summary"] == "failed"
assert classify.call_count == 2
def test_summary_cache_is_bounded_and_tracks_compaction(executor_module, monkeypatch):
from unittest.mock import Mock
from deerflow.subagents import acceptance_checks
classify = Mock(wraps=acceptance_checks.summarize_test_output)
monkeypatch.setattr(acceptance_checks, "summarize_test_output", classify)
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
calls = [{"name": "bash", "id": f"call-{i}", "args": {"command": "node --test"}} for i in range(25)]
ai = AIMessage(content="", tool_calls=calls)
tools = [ToolMessage(content=_summary(), tool_call_id=call["id"]) for call in calls]
cache = {}
for _ in range(3):
entries = executor_module._harvest_bash_executions({"messages": [ai, *tools]}, summary_cache=cache)
assert len(entries) == len(cache) == 20
assert classify.call_count == 20
executor_module._harvest_bash_executions({"messages": [ai, tools[-1]]}, summary_cache=cache)
assert list(cache) == ["call-24"]
assert classify.call_count == 20
executor_module._harvest_bash_executions({"messages": [AIMessage(content="compacted")]}, summary_cache=cache)
assert cache == {}
@pytest.mark.parametrize("node_passed", [0, 1])
@pytest.mark.parametrize(
"sibling,expected",
[
("1 failed, 1 passed, 1 warning in 0.05s", (True, False)),
("2 passed, 3 warnings in 0.05s", (True, True)),
("=== 1 failed, 1 passed, 2 warnings in 0.05s ===", (True, False)),
],
)
def test_node_sibling_pytest_warning_summaries(node_passed, sibling, expected):
verdict = _verdict(_summary(passed=node_passed, skipped=1) + "\n" + sibling)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected
@pytest.mark.parametrize("duration", [59.0, 60.0, 65.0, 3605.0, 86405.0, 172805.0])
@pytest.mark.parametrize("prefix,newline,decoration", [("#", "\n", ""), ("ℹ", "\r\n", "===")])
@pytest.mark.parametrize("node_passed", [0, 1])
@pytest.mark.parametrize("summary,expected", [("1 failed, 1 passed, 1 warning", (True, False)), ("2 passed, 3 warnings", (True, True))])
def test_harvest_preserves_pytest_sibling_duration(executor_module, monkeypatch, duration, prefix, newline, decoration, node_passed, summary, expected):
sibling = f"{decoration} {summary} in {format_session_duration(duration)} {decoration}".strip()
output = (_summary(prefix, passed=node_passed, skipped=1) + "\n" + sibling + "\n" + "diagnostic\n" * 150).replace("\n", newline)
messages = [AIMessage(content="", tool_calls=[{"name": "bash", "id": "mixed-test-call", "args": {"command": "make test"}}]), ToolMessage(content=output, tool_call_id="mixed-test-call")]
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
executions = executor_module._harvest_bash_executions({"messages": messages})
assert len(executions[0]["output_tail"]) <= 1000
assert summary not in executions[0]["output_tail"]
assert executions[0]["test_summary"] == ("passed" if expected[1] else "failed")
verdict = check_acceptance_criteria(["tests_passed:make test"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected
@pytest.mark.parametrize("suffix", [" (extra log text)", " (0:1:05)", " (0:61:05)", " (0:01:05) trailing log text"])
def test_node_sibling_pytest_duration_does_not_admit_arbitrary_suffixes(suffix):
verdict = _verdict(_summary(passed=0, skipped=1) + "\n2 passed in 65.00s" + suffix)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (False, False)
@pytest.mark.parametrize("node_passed", [0, 1])
@pytest.mark.parametrize("cases", ["--- SKIP: TestA (0.00s)", "--- PASS: TestA (0.00s)", "--- SKIP: TestA (0.00s)\n--- PASS: TestB (0.00s)"])
def test_node_sibling_go_preserves_case_evidence(node_passed, cases):
output = _summary(passed=node_passed, skipped=1) + "\n=== RUN TestA\n" + cases + "\nok \texample/pkg\t0.002s"
verdict = _verdict(output)
holds = node_passed > 0 or "--- PASS:" in cases
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (holds, holds)