From e8e9edcb6e2c293f58a4866f9080a2c4241181fa Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Fri, 29 May 2026 23:06:58 +0800 Subject: [PATCH] fix(channels): ignore hidden control messages when extracting replies (#3219) (#3270) --- backend/app/channels/manager.py | 16 ++++++ backend/tests/test_channels.py | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 015f91e58..c245d9448 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -173,6 +173,8 @@ def _extract_response_text(result: dict | list) -> str: # Stop at the last human message — anything before it is a previous turn if msg_type == "human": + if _is_hidden_human_control_message(msg): + continue break # Check for tool messages from ask_clarification (interrupt case) @@ -313,6 +315,8 @@ def _extract_artifacts(result: dict | list) -> list[str]: continue # Stop at the last human message — anything before it is a previous turn if msg.get("type") == "human": + if _is_hidden_human_control_message(msg): + continue break # Look for AI messages with present_files tool calls if msg.get("type") == "ai": @@ -325,6 +329,18 @@ def _extract_artifacts(result: dict | list) -> list[str]: return artifacts +def _is_hidden_human_control_message(msg: Mapping[str, Any]) -> bool: + """Return whether a human message is an internal control message hidden from UI.""" + if msg.get("type") != "human": + return False + + additional_kwargs = msg.get("additional_kwargs") + if not isinstance(additional_kwargs, Mapping): + return False + + return additional_kwargs.get("hide_from_ui") is True + + def _format_artifact_text(artifacts: list[str]) -> str: """Format artifact paths into a human-readable text block listing filenames.""" import posixpath diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 61a402def..0e1c69f50 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -372,6 +372,25 @@ class TestExtractResponseText: # Should return "" (no text in current turn), NOT "Hi there!" from previous turn assert _extract_response_text(result) == "" + def test_ignores_hidden_human_control_messages(self): + """Hidden control messages should not terminate current-turn response extraction.""" + from app.channels.manager import _extract_response_text + + result = { + "messages": [ + {"type": "human", "content": "plan this"}, + {"type": "ai", "content": "Here is the plan."}, + { + "type": "human", + "name": "todo_reminder", + "content": "keep todos updated", + "additional_kwargs": {"hide_from_ui": True}, + }, + ] + } + + assert _extract_response_text(result) == "Here is the plan." + # --------------------------------------------------------------------------- # ChannelManager tests @@ -1678,6 +1697,31 @@ class TestExtractArtifacts: } assert _extract_artifacts(result) == ["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.csv"] + def test_ignores_hidden_human_control_messages(self): + """Hidden control messages should not hide current-turn present_files artifacts.""" + from app.channels.manager import _extract_artifacts + + result = { + "messages": [ + {"type": "human", "content": "export"}, + { + "type": "ai", + "content": "Done.", + "tool_calls": [ + {"name": "present_files", "args": {"filepaths": ["/mnt/user-data/outputs/plan.md"]}}, + ], + }, + { + "type": "human", + "name": "todo_completion_reminder", + "content": "mark tasks complete", + "additional_kwargs": {"hide_from_ui": True}, + }, + ] + } + + assert _extract_artifacts(result) == ["/mnt/user-data/outputs/plan.md"] + class TestFormatArtifactText: def test_single_artifact(self): @@ -1790,6 +1834,50 @@ class TestHandleChatWithArtifacts: _run(go()) + def test_hidden_human_control_message_does_not_trigger_no_response_fallback(self): + """Plan-mode hidden control messages should not mask the final AI response.""" + from app.channels.manager import ChannelManager + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + run_result = { + "messages": [ + {"type": "human", "content": "make a plan"}, + {"type": "ai", "content": "Here is a concrete plan."}, + { + "type": "human", + "name": "todo_reminder", + "content": "sync todos", + "additional_kwargs": {"hide_from_ui": True}, + }, + ] + } + mock_client = _make_mock_langgraph_client(run_result=run_result) + manager._client = mock_client + + outbound_received = [] + bus.subscribe_outbound(lambda msg: outbound_received.append(msg)) + await manager.start() + + await bus.publish_inbound( + InboundMessage( + channel_name="test", + chat_id="c1", + user_id="u1", + text="make a plan", + ) + ) + await _wait_for(lambda: len(outbound_received) >= 1) + await manager.stop() + + assert len(outbound_received) == 1 + assert outbound_received[0].text == "Here is a concrete plan." + + _run(go()) + def test_only_last_turn_artifacts_returned(self): """Only artifacts from the current turn's present_files calls should be included.""" from app.channels.manager import ChannelManager