diff --git a/scripts/release.py b/scripts/release.py index a76dc642c27..084e960f495 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "phanvanhoa@gmail.com": "theAgenticBuilder", # PR #14180 salvage (route delegate_task progress lines through _safe_print so ACP stdio JSON-RPC frames stay clean) "cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown) "tenoryang@outlook.com": "MarioYounger", # PR #9028 salvage (bash/sh heredoc approval, NFKC homograph fold, execute_code CREDS/BEARER/APIKEY env filter) "peet.wannasarnmetha@gmail.com": "peetwan", # PR #51841 salvage (loopback ws-ping tuning + token-frame coalescing + loop heartbeat; #48445/#50005) diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py index 175cd8f6485..fd90dc1b561 100644 --- a/tests/tools/test_delegate_toolset_scope.py +++ b/tests/tools/test_delegate_toolset_scope.py @@ -8,7 +8,7 @@ arbitrary toolsets. from types import SimpleNamespace -from tools.delegate_tool import _strip_blocked_tools +from tools.delegate_tool import _strip_blocked_tools, _emit_parent_console class TestToolsetIntersection: @@ -63,3 +63,44 @@ class TestToolsetIntersection: scoped = [t for t in requested if t in parent_toolsets] assert scoped == [] + + +class TestEmitParentConsole: + """Progress lines (e.g. ``✓ [N/M] …``) must route through the parent's + configured ``_safe_print`` in headless stdio hosts (ACP, gateway) so + they don't land on stdout and corrupt JSON-RPC frames. Regression for a + bug where delegate_task completion lines pushed to stdout caused + ``Failed to parse JSON message: ✓ [3/3] …`` errors in the ACP adapter.""" + + def test_routes_through_parent_safe_print_when_available(self, capsys): + captured_lines = [] + parent = SimpleNamespace(_safe_print=lambda line: captured_lines.append(line)) + + _emit_parent_console(parent, " ✓ [1/3] Research done (11.55s)") + + assert captured_lines == [" ✓ [1/3] Research done (11.55s)"] + stdout_stderr = capsys.readouterr() + assert stdout_stderr.out == "" + assert stdout_stderr.err == "" + + def test_falls_back_to_stdout_when_no_safe_print(self, capsys): + parent = SimpleNamespace() + _emit_parent_console(parent, " ✓ [1/3] fallback path") + captured = capsys.readouterr() + assert "fallback path" in captured.out + + def test_falls_back_to_stdout_when_safe_print_raises(self, capsys): + def raiser(_line): + raise RuntimeError("boom") + + parent = SimpleNamespace(_safe_print=raiser) + _emit_parent_console(parent, " ✓ [2/3] fallback on exception") + captured = capsys.readouterr() + assert "fallback on exception" in captured.out + + def test_non_callable_safe_print_is_ignored(self, capsys): + """Defensive: if _safe_print is set but not callable, fall back.""" + parent = SimpleNamespace(_safe_print="not-a-function") + _emit_parent_console(parent, " ✓ [3/3] non-callable guard") + captured = capsys.readouterr() + assert "non-callable guard" in captured.out diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 49a76318254..7bd510d7f5f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -800,6 +800,24 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: return [t for t in toolsets if t not in blocked_toolset_names] +def _emit_parent_console(parent_agent, line: str) -> None: + """Emit a human-readable progress line to the parent's console. + + Routes through ``parent_agent._safe_print`` when available so headless + stdio hosts (ACP, gateway API) can redirect non-protocol output to + stderr via their configured ``_print_fn``. A bare ``print()`` would + otherwise land on stdout and corrupt JSON-RPC framing. + """ + printer = getattr(parent_agent, "_safe_print", None) + if callable(printer): + try: + printer(line) + return + except Exception: + pass + print(line) + + def _build_child_progress_callback( task_index: int, goal: str, @@ -2610,9 +2628,9 @@ def delegate_task( try: spinner_ref.print_above(completion_line) except Exception: - print(f" {completion_line}") + _emit_parent_console(parent_agent, f" {completion_line}") else: - print(f" {completion_line}") + _emit_parent_console(parent_agent, f" {completion_line}") # Update spinner text to show remaining count if spinner_ref and remaining > 0: