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.
This commit is contained in:
danielhanchen 2026-07-22 08:13:30 +00:00
commit 048460a3f0
2 changed files with 51 additions and 0 deletions

View file

@ -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"

View file

@ -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