fix: guarantee engine replies reach the UI channel and stop same-scope duplicate rows
Project 000, 19:21/20:27: the engine recorded its assistant reply in session_messages, but the post-turn transcript sync never surfaced it in the ui_state channel, and nothing reconciled afterwards — the user watched an empty conversation while the reply sat in the DB. And at 19:13 the same reply was persisted twice in one channel under `<id>` and `<id>::<project>::<channel>`. Two fixes: - _ensure_reply_projected: after the transcript sync, if the session's newest persisted top_level_reply row is absent from the chat store (checked by transcript message id, so nothing user-visible is ever duplicated or leaked), insert and broadcast it directly. Reproduced by test: with the sync disabled the reply previously never reached the channel. - backfill_messages: a live insert racing the backfill snapshot now merges into the existing same-scope row (new _merge_into_same_scope_row, also used by the IntegrityError fallback) instead of minting a `::`-scoped alias id in the message's own channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -299,6 +299,55 @@ class ChatStore:
|
||||
return None
|
||||
return await self._message_scope(str(message_id).strip())
|
||||
|
||||
async def _merge_into_same_scope_row(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
candidate: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Merge ``candidate`` into an already-persisted row with the same id/scope.
|
||||
|
||||
Returns the merged row when the update happened (or nothing changed), or
|
||||
None when the row could not be loaded. Backfill and the live insert path
|
||||
can race on the same message id; the duplicate must merge in place, never
|
||||
be re-inserted under a scoped alias id in the same channel.
|
||||
"""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT message_id, channel_id, sender, sender_name, content, "
|
||||
"timestamp, reply_to_id, mentions, metadata "
|
||||
"FROM messages WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(message_id, channel_id, project_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
existing = self._row_to_message_dict(row)
|
||||
merged = self._merge_duplicate_messages(existing, candidate)
|
||||
if self._message_persisted_equal(existing, merged):
|
||||
return merged
|
||||
merged_timestamp = self._message_timestamp(merged) or time.time()
|
||||
await self._db.execute(
|
||||
"UPDATE messages SET sender = ?, sender_name = ?, content = ?, timestamp = ?, "
|
||||
"reply_to_id = ?, mentions = ?, metadata = ? WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(
|
||||
merged["sender"],
|
||||
merged["sender_name"],
|
||||
merged["content"],
|
||||
merged_timestamp,
|
||||
merged.get("reply_to_id"),
|
||||
json.dumps(merged.get("mentions", [])),
|
||||
json.dumps(merged.get("metadata", {})),
|
||||
message_id,
|
||||
channel_id,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
merged["timestamp"] = merged_timestamp
|
||||
merged["created_at"] = merged_timestamp
|
||||
return merged
|
||||
|
||||
async def _allocate_scoped_message_id(
|
||||
self,
|
||||
message_id: str,
|
||||
@@ -1070,6 +1119,20 @@ class ChatStore:
|
||||
continue
|
||||
|
||||
existing_scope = await self._message_scope(mid)
|
||||
if existing_scope == (channel_id, project_id):
|
||||
# The row appeared after our initial snapshot (a live insert
|
||||
# raced this backfill). Merge in place — never re-insert the
|
||||
# same message under a scoped alias id in its own channel.
|
||||
merged = await self._merge_into_same_scope_row(
|
||||
mid,
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
)
|
||||
if merged is not None:
|
||||
existing_ids.add(mid)
|
||||
existing_messages.append(merged)
|
||||
continue
|
||||
if existing_scope and existing_scope != (channel_id, project_id):
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
metadata.setdefault("ui_message_id", mid)
|
||||
@@ -1114,6 +1177,16 @@ class ChatStore:
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
merged = await self._merge_into_same_scope_row(
|
||||
normalized_message["message_id"],
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
)
|
||||
if merged is not None:
|
||||
existing_ids.add(normalized_message["message_id"])
|
||||
existing_messages.append(merged)
|
||||
continue
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
metadata.setdefault("ui_message_id", normalized_message["message_id"])
|
||||
normalized_message["metadata"] = metadata
|
||||
|
||||
@@ -3068,6 +3068,91 @@ class WSHandler:
|
||||
},
|
||||
})
|
||||
|
||||
async def _ensure_reply_projected(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
session_id: str | None,
|
||||
engine: Any | None = None,
|
||||
) -> None:
|
||||
"""Last-resort invariant: the session's newest persisted top-level reply
|
||||
must exist in the UI channel once the turn has unwound.
|
||||
|
||||
The transcript sync is the normal projection path; when it is starved,
|
||||
cancelled, or misses the row (project 000, 2026-07-07 19:21/20:27), the
|
||||
engine has replied but the user sees an empty conversation forever.
|
||||
Detection is by the transcript message id, so an already-projected reply
|
||||
(any channel) is never duplicated.
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
runtime_engine = engine or self.engine
|
||||
store = getattr(runtime_engine, "store", None)
|
||||
if not self._store_is_ready(store):
|
||||
return
|
||||
lister = getattr(store, "list_session_messages", None)
|
||||
parts_loader = getattr(store, "list_session_parts", None)
|
||||
if not callable(lister) or not callable(parts_loader):
|
||||
return
|
||||
try:
|
||||
records = await lister(session_id)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug("reply projection: failed to list session messages")
|
||||
return
|
||||
latest = None
|
||||
for record in reversed(records or []):
|
||||
if str(getattr(record, "role", "") or "").strip().lower() != "assistant":
|
||||
continue
|
||||
metadata = dict(getattr(record, "metadata", {}) or {})
|
||||
if str(metadata.get("kind", "") or "").strip() != "top_level_reply":
|
||||
continue
|
||||
latest = record
|
||||
break
|
||||
if latest is None:
|
||||
return
|
||||
message_id = str(getattr(latest, "message_id", "") or "").strip()
|
||||
if not message_id:
|
||||
return
|
||||
try:
|
||||
if await self.chat_store.message_scope(message_id) is not None:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
parts = await parts_loader(session_id, message_id)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug("reply projection: failed to load reply parts")
|
||||
return
|
||||
text = "\n".join(
|
||||
chunk
|
||||
for part in parts or []
|
||||
if str(getattr(part, "part_type", "") or "") == "text"
|
||||
for chunk in [str(dict(getattr(part, "payload", {}) or {}).get("text", "") or "")]
|
||||
if chunk
|
||||
).strip()
|
||||
if not text:
|
||||
return
|
||||
logger.warning(
|
||||
f"Top-level reply {message_id} missing from UI channel {channel_id} after "
|
||||
"transcript sync; projecting it directly"
|
||||
)
|
||||
reply_metadata = dict(getattr(latest, "metadata", {}) or {})
|
||||
reply_metadata.setdefault("kind", "top_level_reply")
|
||||
reply_metadata.setdefault("source", "engine")
|
||||
reply_metadata.setdefault("ui_message_id", message_id)
|
||||
reply_metadata["reply_projection_fallback"] = True
|
||||
msg = await self.chat_store.insert_message(
|
||||
channel_id=channel_id,
|
||||
sender="assistant",
|
||||
sender_name="OPC",
|
||||
content=text,
|
||||
project_id=project_id,
|
||||
metadata=reply_metadata,
|
||||
message_id=message_id,
|
||||
)
|
||||
await self.broadcast({"type": "session_message", "payload": msg})
|
||||
|
||||
async def _sync_task_transcript_messages(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -8202,6 +8287,12 @@ class WSHandler:
|
||||
engine=engine,
|
||||
latest_assistant_metadata=checkpoint_meta if checkpoint_meta else None,
|
||||
)
|
||||
await self._ensure_reply_projected(
|
||||
channel_id=channel_id,
|
||||
project_id=pid,
|
||||
session_id=session_id or (str(getattr(task, "session_id", "") or "").strip() if task else None),
|
||||
engine=engine,
|
||||
)
|
||||
|
||||
# ── Status: idle only while the engine left the task active ──
|
||||
store = engine.store
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Invariant: a completed turn's top-level reply must be visible in the UI channel.
|
||||
|
||||
Project 000 forensics (2026-07-07 19:21 / 20:27): the engine recorded the
|
||||
assistant reply in session_messages, but the reply never appeared in the
|
||||
ui_state messages table, so the user stared at an empty conversation. The UI
|
||||
projection of engine replies runs only as a post-turn transcript sync inside
|
||||
_process_session_message; if that step is starved, cancelled, or misses the
|
||||
row, nothing reconciles the channel afterwards.
|
||||
|
||||
These tests drive WSHandler._process_session_message against a real OPCStore
|
||||
and a real ChatStore with the engine's process_message mocked to behave like
|
||||
the incident turn (records the transcript row, returns the reply text).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from opc.core.models import SessionMessageRecord, SessionPartRecord, Task
|
||||
from opc.database.store import OPCStore
|
||||
from opc.plugins.office_ui.chat_store import ChatStore
|
||||
from opc.plugins.office_ui.event_adapter import EventAdapter
|
||||
from opc.plugins.office_ui.ws_handler import WSHandler
|
||||
|
||||
PROJECT_ID = "proj-reply"
|
||||
REPLY_TEXT = (
|
||||
"A legacy company runtime run was found for this session. "
|
||||
"Legacy runs are read-only and cannot be resumed under the work-item runtime."
|
||||
)
|
||||
|
||||
|
||||
class ReplyProjectionInvariantTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
root = Path(self._tmp.name)
|
||||
|
||||
self.store = OPCStore(root / "tasks.db")
|
||||
await self.store.initialize()
|
||||
self.addAsyncCleanup(self.store.close)
|
||||
|
||||
db = await aiosqlite.connect(root / "ui_state.db")
|
||||
self.chat_store = ChatStore(db)
|
||||
await self.chat_store.initialize()
|
||||
self.addAsyncCleanup(db.close)
|
||||
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self.task_id = str(uuid.uuid4())
|
||||
await self.store.save_task(Task(
|
||||
id=self.task_id,
|
||||
title="Competitive analysis",
|
||||
project_id=PROJECT_ID,
|
||||
session_id=self.session_id,
|
||||
))
|
||||
|
||||
self.engine = MagicMock()
|
||||
self.engine.store = self.store
|
||||
self.engine.project_id = PROJECT_ID
|
||||
self.engine.memory = None
|
||||
self.engine.get_latest_pending_checkpoint_for_session = AsyncMock(return_value=None)
|
||||
self.engine.get_pending_checkpoints_for_session = AsyncMock(return_value=[])
|
||||
self.engine.process_message = AsyncMock(side_effect=self._engine_turn)
|
||||
|
||||
self.handler = WSHandler(self.engine, MagicMock(), self.chat_store, EventAdapter())
|
||||
self.broadcasts: list[dict] = []
|
||||
self.handler.broadcast = AsyncMock(side_effect=lambda msg: self.broadcasts.append(msg))
|
||||
self.handler._send_ack = AsyncMock()
|
||||
|
||||
async def _engine_turn(self, content: str, **_kwargs: Any) -> str:
|
||||
"""Mimic the incident turn: persist user + assistant transcript rows,
|
||||
return the reply text (engine's process_message contract)."""
|
||||
for role, text, kind in (
|
||||
("user", content, "top_level_user_turn"),
|
||||
("assistant", REPLY_TEXT, "top_level_reply"),
|
||||
):
|
||||
record = SessionMessageRecord(
|
||||
session_id=self.session_id,
|
||||
role=role,
|
||||
metadata={
|
||||
"project_id": PROJECT_ID,
|
||||
"session_id": self.session_id,
|
||||
"interface": "office_ui",
|
||||
"kind": kind,
|
||||
},
|
||||
)
|
||||
await self.store.save_session_message(record)
|
||||
await self.store.save_session_part(SessionPartRecord(
|
||||
message_id=record.message_id,
|
||||
session_id=self.session_id,
|
||||
part_type="text",
|
||||
payload={"text": text},
|
||||
))
|
||||
return REPLY_TEXT
|
||||
|
||||
async def _channel_contents(self) -> list[tuple[str, str]]:
|
||||
channel_id = f"session:{self.task_id}"
|
||||
cursor = await self.chat_store._db.execute(
|
||||
"SELECT sender, content FROM messages WHERE channel_id = ? AND project_id = ? "
|
||||
"ORDER BY timestamp",
|
||||
(channel_id, PROJECT_ID),
|
||||
)
|
||||
return [(str(row[0]), str(row[1])) for row in await cursor.fetchall()]
|
||||
|
||||
async def test_reply_reaches_ui_channel_after_turn(self) -> None:
|
||||
await self.handler._process_session_message(
|
||||
self.task_id,
|
||||
"你的交付文件在哪里?",
|
||||
session_id=self.session_id,
|
||||
)
|
||||
|
||||
rows = await self._channel_contents()
|
||||
assistant_rows = [content for sender, content in rows if sender != "user"]
|
||||
self.assertTrue(
|
||||
any(REPLY_TEXT.split(".")[0] in content for content in assistant_rows),
|
||||
f"assistant reply missing from UI channel; channel rows: {rows!r}",
|
||||
)
|
||||
|
||||
async def test_reply_projected_even_if_transcript_sync_misses(self) -> None:
|
||||
"""The last-resort projection must cover sync failures (starvation,
|
||||
cancellation, mapping defects) — the incident's exact shape."""
|
||||
self.handler._sync_task_transcript_messages = AsyncMock(return_value=0)
|
||||
|
||||
await self.handler._process_session_message(
|
||||
self.task_id,
|
||||
"你的交付文件在哪里?",
|
||||
session_id=self.session_id,
|
||||
)
|
||||
|
||||
rows = await self._channel_contents()
|
||||
assistant_rows = [content for sender, content in rows if sender != "user"]
|
||||
self.assertTrue(
|
||||
any(REPLY_TEXT.split(".")[0] in content for content in assistant_rows),
|
||||
f"assistant reply missing from UI channel; channel rows: {rows!r}",
|
||||
)
|
||||
# And it must not double-insert when the sync did work: run a normal
|
||||
# turn in the same channel and count copies of its reply.
|
||||
self.handler._sync_task_transcript_messages = WSHandler._sync_task_transcript_messages.__get__(self.handler)
|
||||
await self.handler._process_session_message(
|
||||
self.task_id,
|
||||
"再问一次",
|
||||
session_id=self.session_id,
|
||||
)
|
||||
rows = await self._channel_contents()
|
||||
copies = [content for sender, content in rows if sender != "user" and REPLY_TEXT[:40] in content]
|
||||
self.assertLessEqual(len(copies), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6476,6 +6476,50 @@ class TestSnapshotBuilderSessionData(unittest.IsolatedAsyncioTestCase):
|
||||
finally:
|
||||
await chat_store._db.close()
|
||||
|
||||
async def test_backfill_merges_same_scope_row_that_raced_the_snapshot(self) -> None:
|
||||
"""A live insert landing after the backfill snapshot must merge in place,
|
||||
never persist a second copy under a `::`-scoped alias id (project 000:
|
||||
the same reply was stored twice in one channel)."""
|
||||
chat_store = await _make_chat_store()
|
||||
try:
|
||||
await chat_store.create_session_channel("race-task", "Race", project_id="p1")
|
||||
channel_id = "session:race-task"
|
||||
|
||||
original_scope = chat_store._message_scope
|
||||
|
||||
async def racing_scope(message_id: str):
|
||||
# Simulate the live insert path landing the same row after the
|
||||
# backfill snapshot was taken but before its INSERT runs.
|
||||
if message_id == "engine-race-1" and await original_scope(message_id) is None:
|
||||
await chat_store.insert_message(
|
||||
channel_id=channel_id,
|
||||
sender="assistant",
|
||||
sender_name="OPC",
|
||||
content="Reply text",
|
||||
message_id="engine-race-1",
|
||||
project_id="p1",
|
||||
metadata={"note": "live-copy"},
|
||||
)
|
||||
return await original_scope(message_id)
|
||||
|
||||
chat_store._message_scope = racing_scope
|
||||
|
||||
await chat_store.backfill_messages(channel_id, [{
|
||||
"message_id": "engine-race-1",
|
||||
"sender": "assistant",
|
||||
"sender_name": "OPC",
|
||||
"content": "Reply text",
|
||||
"timestamp": time.time(),
|
||||
"metadata": {"source": "engine", "role": "assistant"},
|
||||
}], project_id="p1")
|
||||
|
||||
rows = await chat_store.get_channel_messages(channel_id, limit=20, project_id="p1")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["message_id"], "engine-race-1")
|
||||
self.assertNotIn("::", rows[0]["message_id"])
|
||||
finally:
|
||||
await chat_store._db.close()
|
||||
|
||||
async def test_build_collab_sync_marks_primary_as_company_runtime_from_children(self) -> None:
|
||||
"""Legacy primary sessions should still be marked as company runtimes when child work items exist."""
|
||||
from opc.plugins.office_ui.snapshot_builder import build_collab_sync
|
||||
|
||||
Reference in New Issue
Block a user