fix: keep ACP stdout protocol-clean

Route AIAgent print output to stderr via _print_fn for ACP stdio sessions.
Gate quiet-mode spinner startup on _should_start_quiet_spinner() so JSON-RPC
on stdout isn't corrupted. Child agents inherit the redirect.

Co-authored-by: Git-on-my-level <Git-on-my-level@users.noreply.github.com>
This commit is contained in:
Git-on-my-level
2026-04-05 11:42:13 -07:00
committed by Teknium
parent 914a7db448
commit fcdd5447e2
6 changed files with 160 additions and 8 deletions

View File

@@ -1491,6 +1491,25 @@ class AIAgent:
return
self._safe_print(*args, **kwargs)
def _should_start_quiet_spinner(self) -> bool:
"""Return True when quiet-mode spinner output has a safe sink.
In headless/stdio-protocol environments, a raw spinner with no custom
``_print_fn`` falls back to ``sys.stdout`` and can corrupt protocol
streams such as ACP JSON-RPC. Allow quiet spinners only when either:
- output is explicitly rerouted via ``_print_fn``; or
- stdout is a real TTY.
"""
if self._print_fn is not None:
return True
stream = getattr(sys, "stdout", None)
if stream is None:
return False
try:
return bool(stream.isatty())
except (AttributeError, ValueError, OSError):
return False
def _emit_status(self, message: str) -> None:
"""Emit a lifecycle status message to both CLI and gateway channels.
@@ -6066,7 +6085,7 @@ class AIAgent:
# Start spinner for CLI mode (skip when TUI handles tool progress)
spinner = None
if self.quiet_mode and not self.tool_progress_callback:
if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
spinner = KawaiiSpinner(f"{face} ⚡ running {num_tools} tools concurrently", spinner_type='dots', print_fn=self._print_fn)
spinner.start()
@@ -6294,7 +6313,7 @@ class AIAgent:
goal_preview = (function_args.get("goal") or "")[:30]
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
spinner = None
if self.quiet_mode and not self.tool_progress_callback:
if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner():
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
spinner = KawaiiSpinner(f"{face} {spinner_label}", spinner_type='dots', print_fn=self._print_fn)
spinner.start()
@@ -7120,9 +7139,9 @@ class AIAgent:
# CLI TUI mode: use prompt_toolkit widget instead of raw spinner
# (works in both streaming and non-streaming modes)
self.thinking_callback(f"{face} {verb}...")
elif not self._has_stream_consumers():
# Raw KawaiiSpinner only when no streaming consumers
# (would conflict with streamed token output)
elif not self._has_stream_consumers() and self._should_start_quiet_spinner():
# Raw KawaiiSpinner only when no streaming consumers and the
# spinner output has a safe sink.
spinner_type = random.choice(['brain', 'sparkle', 'pulse', 'moon', 'star'])
thinking_spinner = KawaiiSpinner(f"{face} {verb}...", spinner_type=spinner_type, print_fn=self._print_fn)
thinking_spinner.start()