From f086cce86e6157d63a412f57fdba9bccd826c188 Mon Sep 17 00:00:00 2001 From: Luck <2115759892@qq.com> Date: Sat, 14 Nov 2026 17:08:28 +0800 Subject: [PATCH] fix(channels): deliver each presented file once, in presentation order (#8209) * fix(channels): deliver each presented file once, in presentation order _extract_artifacts scanned the turn newest-first and kept every present_files path, so a file presented twice in one turn was uploaded twice and files from separate calls arrived newest call first. Keep the first-presented order without repeats, as the merge_artifacts reducer does for the web UI. Co-Authored-By: Claude Opus 5.5 * test(channels): pin dedup of a path repeated inside one present_files call Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/app/channels/manager.py | 9 ++++-- backend/tests/test_channels.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index e8dbbed743..5d309e1eed 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -1243,13 +1243,18 @@ def _extract_artifacts(result: dict | list, *, thread_id: str | None = None) -> break # Look for AI messages with present_files tool calls if msg.get("type") == "ai": + message_paths: list[str] = [] for tc in msg.get("tool_calls", []): if isinstance(tc, dict) and tc.get("name") == "present_files": args = tc.get("args", {}) paths = args.get("filepaths", []) if isinstance(paths, list): - artifacts.extend(p for p in paths if isinstance(p, str)) - return artifacts + message_paths.extend(p for p in paths if isinstance(p, str)) + # The scan runs newest-first; prepend so earlier calls stay first. + artifacts[:0] = message_paths + # Match the ``merge_artifacts`` reducer: first-presented order, no repeats, + # so a file presented twice in one turn is uploaded once. + return list(dict.fromkeys(artifacts)) def _is_hidden_human_control_message(msg: Mapping[str, Any], *, thread_id: str | None = None) -> bool: diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 179952a084..505eca0cfe 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -7511,6 +7511,58 @@ class TestExtractArtifacts: } assert _extract_artifacts(result) == ["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.csv"] + def test_multiple_calls_keep_presentation_order_and_deduplicate(self): + """A file presented twice in one turn is delivered once, in the order presented. + + ``present_files`` relies on the ``merge_artifacts`` reducer, which keeps + first-presented order and drops repeats; the IM reply must match it. + """ + from app.channels.manager import _extract_artifacts + + result = { + "messages": [ + {"type": "human", "content": "chart then report"}, + { + "type": "ai", + "content": "", + "tool_calls": [{"name": "present_files", "args": {"filepaths": ["/mnt/user-data/outputs/chart.png"]}}], + }, + {"type": "tool", "name": "present_files", "content": "Successfully presented files"}, + { + "type": "ai", + "content": "", + "tool_calls": [ + {"name": "present_files", "args": {"filepaths": ["/mnt/user-data/outputs/report.md", "/mnt/user-data/outputs/chart.png"]}}, + ], + }, + {"type": "tool", "name": "present_files", "content": "Successfully presented files"}, + {"type": "ai", "content": "Done."}, + ] + } + + assert _extract_artifacts(result) == ["/mnt/user-data/outputs/chart.png", "/mnt/user-data/outputs/report.md"] + + def test_single_call_with_repeated_path_delivers_it_once(self): + """``merge_artifacts`` also drops repeats inside one ``present_files`` call.""" + from app.channels.manager import _extract_artifacts + + result = { + "messages": [ + {"type": "human", "content": "send the chart"}, + { + "type": "ai", + "content": "", + "tool_calls": [ + {"name": "present_files", "args": {"filepaths": ["/mnt/user-data/outputs/chart.png", "/mnt/user-data/outputs/report.md", "/mnt/user-data/outputs/chart.png"]}}, + ], + }, + {"type": "tool", "name": "present_files", "content": "Successfully presented files"}, + {"type": "ai", "content": "Done."}, + ] + } + + assert _extract_artifacts(result) == ["/mnt/user-data/outputs/chart.png", "/mnt/user-data/outputs/report.md"] + 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