diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e806207..7dfcc1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.493] — 2026-06-18 — Release RC (ignore malformed background-process wakeup events) + +### Fixed + +- **Malformed background-process wakeup events no longer appear as empty `unknown completed` prompts.** WebUI now ignores empty or unknown completion-queue payloads and renders watch-pattern overflow summaries as explicit watch notifications instead of fake process completions. Thanks @ai-ag2026. + ## [v0.51.492] — 2026-06-18 — Release RB (large context window preserved on session reload) ### Fixed diff --git a/api/background_process.py b/api/background_process.py index 46801949..ab82083f 100644 --- a/api/background_process.py +++ b/api/background_process.py @@ -350,17 +350,28 @@ def _truncate(text: str, limit: int) -> str: return s[:limit] + "\n…(truncated)" -def format_wakeup_prompt(evt: dict) -> str: +def format_wakeup_prompt(evt: object) -> str | None: """Build the synthetic [IMPORTANT: …] message the agent will see. Mirrors ``cli._format_process_notification`` so wakeup payloads look the same in CLI and WebUI sessions. """ + if not isinstance(evt, dict) or not evt: + return None + evt_type = evt.get("type", "completion") - sid = evt.get("session_id", "unknown") - cmd = evt.get("command", "unknown") + sid = str(evt.get("session_id") or "").strip() + cmd = str(evt.get("command") or "").strip() + # The current server-side wakeup drain drops global watch-overflow events + # before this formatter because they intentionally carry no session_key. + # Keep this branch defensive so any future routable overflow summary is not + # mis-rendered as a fake process completion. + if evt_type in {"watch_overflow_tripped", "watch_overflow_released"}: + msg = str(evt.get("message") or "").strip() + return f"[IMPORTANT: {msg}]" if msg else None if evt_type == "watch_disabled": - return f"[IMPORTANT: {evt.get('message', '')}]" + msg = str(evt.get("message") or "").strip() + return f"[IMPORTANT: {msg}]" if msg else None if evt_type == "watch_match": pat = evt.get("pattern", "?") out = _truncate(evt.get("output", ""), 4000) @@ -373,6 +384,12 @@ def format_wakeup_prompt(evt: dict) -> str: if sup: body += f"\n({sup} earlier matches were suppressed by rate limit)" return body + "]" + if evt_type != "completion": + return None + + if not (sid or cmd or "exit_code" in evt or evt.get("output")): + return None + # Default: completion event exit_code = evt.get("exit_code", "?") out = _truncate(evt.get("output", ""), 4000) @@ -880,7 +897,8 @@ def _process_one(evt: dict) -> None: # `{session_id, task_id, completed_at, summary?, event_id}`, so # we derive the prompt directly from the evt here (same source the # prior _build_payload used). - wakeup_prompt = format_wakeup_prompt(evt).strip() + wakeup_prompt_raw = format_wakeup_prompt(evt) + wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else "" if wakeup_prompt: if _session_has_active_turn(session_id): # Defer-path fix: persist the prompt so a turn-teardown diff --git a/tests/test_background_process_wakeup_format.py b/tests/test_background_process_wakeup_format.py new file mode 100644 index 00000000..16e2bcf1 --- /dev/null +++ b/tests/test_background_process_wakeup_format.py @@ -0,0 +1,78 @@ +from api.background_process import format_wakeup_prompt + + +def test_format_wakeup_prompt_skips_empty_event(): + assert format_wakeup_prompt({}) is None + + +def test_format_wakeup_prompt_skips_non_dict_event(): + assert format_wakeup_prompt(None) is None + assert format_wakeup_prompt(42) is None + + +def test_format_wakeup_prompt_skips_unknown_event_type(): + evt = {"type": "unknown_event", "message": "do not inject me"} + assert format_wakeup_prompt(evt) is None + + +def test_format_wakeup_prompt_handles_watch_disabled(): + evt = { + "type": "watch_disabled", + "session_id": "proc_abc", + "session_key": "webui-session", + "command": "tail -f log", + "message": "Watch patterns disabled for process proc_abc.", + } + assert ( + format_wakeup_prompt(evt) + == "[IMPORTANT: Watch patterns disabled for process proc_abc.]" + ) + + +def test_format_wakeup_prompt_skips_blank_watch_disabled(): + assert format_wakeup_prompt({"type": "watch_disabled"}) is None + assert format_wakeup_prompt({"type": "watch_disabled", "message": ""}) is None + + +def test_format_wakeup_prompt_handles_watch_overflow_tripped(): + evt = { + "type": "watch_overflow_tripped", + "session_id": "", + "session_key": "", + "command": "", + "message": "Watch-pattern overflow: suppressing further watch_match events.", + } + assert ( + format_wakeup_prompt(evt) + == "[IMPORTANT: Watch-pattern overflow: suppressing further watch_match events.]" + ) + + +def test_format_wakeup_prompt_handles_watch_overflow_released(): + evt = { + "type": "watch_overflow_released", + "session_id": "", + "session_key": "", + "command": "", + "suppressed": 3, + "message": "Watch-pattern notifications resumed. 3 match event(s) were suppressed.", + } + assert ( + format_wakeup_prompt(evt) + == "[IMPORTANT: Watch-pattern notifications resumed. 3 match event(s) were suppressed.]" + ) + + +def test_format_wakeup_prompt_keeps_normal_completion(): + evt = { + "type": "completion", + "session_id": "proc_abc", + "command": "sleep 1", + "exit_code": 0, + "output": "done", + } + result = format_wakeup_prompt(evt) + assert result is not None + assert "Background process proc_abc completed" in result + assert "Command: sleep 1" in result + assert "Output:\ndone" in result