172 lines
6.0 KiB
Python
172 lines
6.0 KiB
Python
"""Subagent failures surface as one clean user-facing notice.
|
|
|
|
Covers the Aug 2026 community report: a delegate_task child that dies
|
|
(provider 404, timeout, crash) previously vanished silently on platforms
|
|
with tool_progress off — the parent model saw the error but the human never
|
|
did. Now:
|
|
|
|
- ``tools.delegate_tool.format_subagent_failure_line`` renders one clean,
|
|
human-readable line (no tracebacks/JSON walls).
|
|
- ``TurnRunner.progress_callback`` intercepts ``subagent.complete`` events
|
|
with a terminal failure status FIRST (before every progress-queue gate)
|
|
and delivers the line via ``_deliver_platform_notice``.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.turn_context import TurnContext
|
|
from tools.delegate_tool import (
|
|
SUBAGENT_FAILURE_STATUSES,
|
|
_clean_error_text,
|
|
format_subagent_failure_line,
|
|
)
|
|
|
|
|
|
class TestCleanErrorText:
|
|
def test_single_line_passthrough(self):
|
|
assert _clean_error_text("Error code: 404 - model not found") == (
|
|
"Error code: 404 - model not found"
|
|
)
|
|
|
|
def test_traceback_takes_last_line(self):
|
|
tb = (
|
|
"Traceback (most recent call last):\n"
|
|
' File "x.py", line 1, in <module>\n'
|
|
" raise RuntimeError('boom')\n"
|
|
"RuntimeError: boom"
|
|
)
|
|
assert _clean_error_text(tb) == "RuntimeError: boom"
|
|
|
|
def test_multiline_non_traceback_takes_first_line(self):
|
|
assert _clean_error_text("first line\nsecond line") == "first line"
|
|
|
|
def test_caps_length(self):
|
|
out = _clean_error_text("x" * 500, max_chars=100)
|
|
assert len(out) == 100
|
|
assert out.endswith("...")
|
|
|
|
def test_empty_and_none(self):
|
|
assert _clean_error_text("") == ""
|
|
assert _clean_error_text(None) == ""
|
|
assert _clean_error_text(" \n ") == ""
|
|
|
|
|
|
class TestFormatSubagentFailureLine:
|
|
def test_failed_with_goal_error_duration(self):
|
|
line = format_subagent_failure_line(
|
|
"research competitor pricing",
|
|
"failed",
|
|
error="Error code: 404 - model not found",
|
|
duration_seconds=12.4,
|
|
)
|
|
assert line.startswith("⚠️ Subagent failed")
|
|
assert '"research competitor pricing"' in line
|
|
assert "404" in line
|
|
assert "(after 12s)" in line
|
|
|
|
def test_timeout_verb(self):
|
|
line = format_subagent_failure_line("do a thing", "timeout")
|
|
assert "timed out" in line
|
|
|
|
def test_long_goal_truncated(self):
|
|
line = format_subagent_failure_line("g" * 200, "failed")
|
|
assert "g" * 57 + "..." in line
|
|
assert "g" * 61 not in line
|
|
|
|
def test_no_goal_no_error(self):
|
|
line = format_subagent_failure_line(None, "error")
|
|
assert line == "⚠️ Subagent failed"
|
|
|
|
def test_multiline_goal_flattened(self):
|
|
line = format_subagent_failure_line("a\nb", "failed")
|
|
assert "\n" not in line
|
|
|
|
def test_failure_statuses_frozen(self):
|
|
assert SUBAGENT_FAILURE_STATUSES == {"failed", "error", "timeout"}
|
|
|
|
|
|
def _make_runner_and_captured(monkeypatch, run_still_current=True):
|
|
"""TurnRunner with a stub gateway runner; captures scheduled notices."""
|
|
from gateway import run as run_mod
|
|
|
|
captured: list[str] = []
|
|
|
|
class _StubGatewayRunner:
|
|
def _adapter_for_source(self, source):
|
|
return None
|
|
|
|
async def _deliver_platform_notice(self, source, content):
|
|
captured.append(content)
|
|
|
|
def _fake_schedule(coro, loop, logger=None, log_message=None):
|
|
asyncio.run(coro)
|
|
|
|
monkeypatch.setattr(run_mod, "safe_schedule_threadsafe", _fake_schedule)
|
|
|
|
ctx = TurnContext(
|
|
source=MagicMock(),
|
|
_run_still_current=lambda: run_still_current,
|
|
progress_queue=None,
|
|
_loop_for_step=None,
|
|
)
|
|
return run_mod.TurnRunner(_StubGatewayRunner(), ctx), captured
|
|
|
|
|
|
class TestGatewayFailureNotice:
|
|
@pytest.mark.parametrize("status", sorted(SUBAGENT_FAILURE_STATUSES))
|
|
def test_failure_statuses_deliver_notice(self, monkeypatch, status):
|
|
runner, captured = _make_runner_and_captured(monkeypatch)
|
|
runner.progress_callback(
|
|
"subagent.complete",
|
|
preview="Error code: 404 - model not found",
|
|
status=status,
|
|
goal="scan the repo",
|
|
duration_seconds=8.0,
|
|
)
|
|
assert len(captured) == 1
|
|
assert "Subagent" in captured[0]
|
|
assert "404" in captured[0]
|
|
assert '"scan the repo"' in captured[0]
|
|
|
|
@pytest.mark.parametrize("status", ["completed", "interrupted", None])
|
|
def test_non_failure_statuses_stay_silent(self, monkeypatch, status):
|
|
runner, captured = _make_runner_and_captured(monkeypatch)
|
|
runner.progress_callback(
|
|
"subagent.complete", preview="all done", status=status, goal="g"
|
|
)
|
|
assert captured == []
|
|
|
|
def test_stale_run_stays_silent(self, monkeypatch):
|
|
runner, captured = _make_runner_and_captured(
|
|
monkeypatch, run_still_current=False
|
|
)
|
|
runner.progress_callback(
|
|
"subagent.complete", preview="boom", status="failed", goal="g"
|
|
)
|
|
assert captured == []
|
|
|
|
def test_fires_without_progress_queue(self, monkeypatch):
|
|
"""The notice must not depend on tool_progress being enabled —
|
|
progress_queue=None is exactly the Telegram/Slack default where the
|
|
silent-failure report came from."""
|
|
runner, captured = _make_runner_and_captured(monkeypatch)
|
|
assert runner._ctx.progress_queue is None
|
|
runner.progress_callback(
|
|
"subagent.complete", preview="err", status="error", goal="g"
|
|
)
|
|
assert len(captured) == 1
|
|
|
|
def test_summary_preferred_over_preview(self, monkeypatch):
|
|
runner, captured = _make_runner_and_captured(monkeypatch)
|
|
runner.progress_callback(
|
|
"subagent.complete",
|
|
preview="short preview",
|
|
status="failed",
|
|
goal="g",
|
|
summary="the real error detail",
|
|
)
|
|
assert "the real error detail" in captured[0]
|