From 048460a3f056c99ea6e3f7713c099cd76510ad77 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 22 Jul 2026 08:13:30 +0000 Subject: [PATCH] Studio: do not bind a research run to a populated assistant reply create_run adopted any assistant message under the user turn whose researchRunId was unset, including a prior answer reused by a retry. On completion _update_assistant drops the untagged text and source parts, so that answer was silently overwritten. Only bind to an empty placeholder or this run's own message, and reject a reply that already carries content. --- studio/backend/storage/research_runs_db.py | 14 +++++++ .../tests/test_research_runs_storage.py | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/studio/backend/storage/research_runs_db.py b/studio/backend/storage/research_runs_db.py index 564510da7e..72d8971bdd 100644 --- a/studio/backend/storage/research_runs_db.py +++ b/studio/backend/storage/research_runs_db.py @@ -192,11 +192,25 @@ def create_run( if isinstance(existing_metadata, dict) else None ) + # Only bind to an empty placeholder or this run's own message. An + # untagged reply carries text/source parts that _update_assistant + # drops on completion, so binding one silently overwrites an + # existing answer (for example a retry reusing a prior answer id). + existing_answer = any( + isinstance(part, dict) + and ( + (part.get("type") == "text" and (part.get("text") or "").strip()) + or part.get("type") == "source" + ) + and part.get("researchRunId") is None + for part in _loads(message["content_json"], []) + ) if ( message["thread_id"] != thread_id or message["role"] != "assistant" or message["parent_id"] != user_message_id or existing_run_id not in (None, run_id) + or (existing_run_id is None and existing_answer) ): raise ResearchConflictError( "Assistant message does not match this research run" diff --git a/studio/backend/tests/test_research_runs_storage.py b/studio/backend/tests/test_research_runs_storage.py index c336ad8629..0e9bf3b91b 100644 --- a/studio/backend/tests/test_research_runs_storage.py +++ b/studio/backend/tests/test_research_runs_storage.py @@ -2208,6 +2208,43 @@ def test_create_run_conflict_rolls_back_placeholder_and_run(research_home): assert studio_db.get_chat_message("thread-1", "conflict")["parentId"] is None +def test_create_run_rejects_binding_to_populated_reply(research_home): + # A prior answer under the same user turn (untagged, no researchRunId) must + # not be adopted as the placeholder: _update_assistant would drop its + # text/source parts on completion and silently overwrite that answer. + studio_db.upsert_chat_message( + { + "id": "prior-answer", + "threadId": "thread-1", + "parentId": "user-1", + "role": "assistant", + "content": [ + {"type": "text", "text": "existing answer"}, + {"type": "source", "sourceType": "url", "url": "https://kept.example"}, + ], + "createdAt": 4, + } + ) + with pytest.raises(research_db.ResearchConflictError): + _create(assistant_message_id = "prior-answer") + assert research_db.get_run("run-1") is None + preserved = studio_db.get_chat_message("thread-1", "prior-answer") + assert preserved["content"][0]["text"] == "existing answer" + # An empty placeholder under the same turn is still accepted. + studio_db.upsert_chat_message( + { + "id": "empty-placeholder", + "threadId": "thread-1", + "parentId": "user-1", + "role": "assistant", + "content": [], + "createdAt": 5, + } + ) + run = _create(assistant_message_id = "empty-placeholder") + assert run["assistantMessageId"] == "empty-placeholder" + + def test_update_assistant_replaces_report_parts_without_duplication(research_home): from core.research_runs import _update_assistant