fix: deduplicate re-delivered session sends on the client message id
Typed-3-shown-5 forensics (project 000): the WS client queues session_send payloads while disconnected and flushes the queue after a reconnect, and the server minted a fresh row id per delivery — so one typed message could land as several user turns, each dispatched to the engine. Every send now carries a client-generated ui_message_id (dispatchSessionSend injects one when the caller didn't). The handler persists the user row under that id and answers any later delivery in the same channel with an idempotent ack instead of inserting and dispatching again. Because the row id now equals the optimistic bubble's ui_message_id, the echo also merges with the local message even after the transcript sync rewrites row metadata. Same text intentionally sent again gets a fresh id and still starts a new turn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -288,6 +288,17 @@ class ChatStore:
|
||||
return None
|
||||
return str(row[0] or ""), str(row[1] or "default")
|
||||
|
||||
async def message_scope(self, message_id: str) -> tuple[str, str] | None:
|
||||
"""(channel_id, project_id) of a persisted message, or None if absent.
|
||||
|
||||
Used for idempotent client sends: a re-delivered ``session_send`` carries
|
||||
the same client-generated ``ui_message_id``, so an existing row in the
|
||||
same scope identifies the duplicate.
|
||||
"""
|
||||
if not str(message_id or "").strip():
|
||||
return None
|
||||
return await self._message_scope(str(message_id).strip())
|
||||
|
||||
async def _allocate_scoped_message_id(
|
||||
self,
|
||||
message_id: str,
|
||||
|
||||
+51
-51
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>OpenOPC Pixel Office</title>
|
||||
<script type="module" crossorigin src="./assets/index-CYE7dSgZ.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-D7RDFfgf.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CMqG6mW8.css">
|
||||
</head>
|
||||
|
||||
@@ -10,5 +10,10 @@ assert.match(src, /makeOptimisticUserMessageId/, 'ordinary composer sends must c
|
||||
assert.match(src, /chatStore\.sendMessage/, 'ordinary composer sends must echo the user message locally before backend response')
|
||||
assert.match(src, /ui_message_id: uiMessageId/, 'optimistic local message and websocket metadata must share ui_message_id')
|
||||
assert.match(src, /checkpointReplyId/, 'checkpoint replies must be excluded from ordinary optimistic composer echo')
|
||||
assert.match(
|
||||
src,
|
||||
/const outgoing = metadata\?\.ui_message_id\s*\?\s*metadata\s*:\s*\{ \.\.\.\(metadata \?\? \{\}\), ui_message_id: makeOptimisticUserMessageId\(\) \}/,
|
||||
'every session send must carry a client-generated ui_message_id so the backend can deduplicate re-deliveries',
|
||||
)
|
||||
|
||||
console.log('WorkspacePage.test.ts: OK (optimistic composer echo wiring)')
|
||||
|
||||
@@ -943,7 +943,12 @@ export function WorkspacePage({
|
||||
attachments?: OutgoingAttachmentPayload[],
|
||||
metadata?: CheckpointReplyMetadata,
|
||||
) => {
|
||||
onSessionSend(taskId, content, attachments, metadata)
|
||||
// Every send carries a client-generated ui_message_id so the backend can
|
||||
// deduplicate re-deliveries (WS pending-queue flush after a reconnect).
|
||||
const outgoing = metadata?.ui_message_id
|
||||
? metadata
|
||||
: { ...(metadata ?? {}), ui_message_id: makeOptimisticUserMessageId() }
|
||||
onSessionSend(taskId, content, attachments, outgoing)
|
||||
}, [onSessionSend])
|
||||
|
||||
// ── Composer send ──
|
||||
|
||||
@@ -5742,6 +5742,34 @@ class WSHandler:
|
||||
if normalized_answers:
|
||||
reply_metadata["user_input_answers"] = normalized_answers
|
||||
|
||||
# Idempotency on the client-generated message id: the WS client queues
|
||||
# sends while disconnected and flushes the queue after a reconnect, so
|
||||
# one typed message can be delivered more than once. The first delivery
|
||||
# persisted a row under this id in this channel; later copies are
|
||||
# acknowledged and dropped instead of dispatching a duplicate turn.
|
||||
client_message_id = str(reply_metadata.get("ui_message_id", "") or "").strip()
|
||||
if client_message_id:
|
||||
existing_scope = await self.chat_store.message_scope(client_message_id)
|
||||
if existing_scope == (channel_id, pid):
|
||||
logger.info(
|
||||
f"session_send deduplicated re-delivered client message {client_message_id} "
|
||||
f"for task {task_id}"
|
||||
)
|
||||
await self._send_ack(
|
||||
ws,
|
||||
ok=True,
|
||||
action="session_send",
|
||||
task_id=task_id,
|
||||
project_id=pid,
|
||||
deduplicated=True,
|
||||
message_id=client_message_id,
|
||||
)
|
||||
return
|
||||
if existing_scope is not None:
|
||||
# Same id already used in another channel/project: never reuse it
|
||||
# as a row id there (insert_message REPLACEs by primary key).
|
||||
client_message_id = ""
|
||||
|
||||
explicit_checkpoint_id = str(reply_metadata.get("response_to_checkpoint_id", "")).strip()
|
||||
explicit_checkpoint_type = str(reply_metadata.get("response_to_checkpoint_type", "")).strip()
|
||||
explicit_escalation_id = str(reply_metadata.get("response_to_escalation_id", "")).strip()
|
||||
@@ -5900,6 +5928,9 @@ class WSHandler:
|
||||
content=content,
|
||||
project_id=pid,
|
||||
metadata=msg_metadata if msg_metadata else None,
|
||||
# Persist under the client-generated id so re-deliveries of the same
|
||||
# send are detectable and the optimistic bubble merges with the echo.
|
||||
message_id=client_message_id or None,
|
||||
)
|
||||
await self.broadcast({"type": "session_message", "payload": msg})
|
||||
|
||||
|
||||
@@ -791,6 +791,67 @@ class TestWSHandlerSessionSend(unittest.IsolatedAsyncioTestCase):
|
||||
metadata = json.loads(rows[0][2])
|
||||
self.assertEqual(metadata.get("ui_message_id"), "ui-message-1")
|
||||
|
||||
async def test_session_send_duplicate_delivery_is_deduplicated(self) -> None:
|
||||
"""A re-delivered send with the same client ui_message_id (WS pending-queue
|
||||
flush after a reconnect) must not create a second row or a second turn."""
|
||||
ws = MagicMock()
|
||||
dispatched: list = []
|
||||
|
||||
def _record_dispatch(_task_id: str, coro: Any, **_kwargs: Any) -> None:
|
||||
dispatched.append(_task_id)
|
||||
coro.close()
|
||||
|
||||
self.handler._track_session = MagicMock(side_effect=_record_dispatch)
|
||||
|
||||
payload = {
|
||||
"project_id": "test-project",
|
||||
"task_id": self.task_id,
|
||||
"content": "你的交付文件在哪里?",
|
||||
"metadata": {"ui_message_id": "ui-dup-1"},
|
||||
}
|
||||
await self.handler._handle_session_send(ws, dict(payload))
|
||||
await self.handler._handle_session_send(ws, dict(payload))
|
||||
|
||||
channel_id = f"session:{self.task_id}"
|
||||
cursor = await self.chat_store._db.execute(
|
||||
"SELECT message_id FROM messages WHERE channel_id = ? AND sender = 'user'",
|
||||
(channel_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual(len(rows), 1)
|
||||
# The row is persisted under the client id so later copies are detectable.
|
||||
self.assertEqual(rows[0][0], "ui-dup-1")
|
||||
self.assertEqual(len(dispatched), 1)
|
||||
|
||||
dedup_acks = [
|
||||
call.kwargs
|
||||
for call in self.handler._send_ack.await_args_list
|
||||
if call.kwargs.get("deduplicated")
|
||||
]
|
||||
self.assertEqual(len(dedup_acks), 1)
|
||||
self.assertEqual(dedup_acks[0].get("message_id"), "ui-dup-1")
|
||||
|
||||
async def test_session_send_same_text_new_id_is_a_new_turn(self) -> None:
|
||||
"""Deliberately re-asking the same question (fresh ui_message_id) still works."""
|
||||
ws = MagicMock()
|
||||
self.handler._track_session = MagicMock(side_effect=self._discard_session_dispatch)
|
||||
|
||||
for ui_id in ("ui-ask-1", "ui-ask-2"):
|
||||
await self.handler._handle_session_send(ws, {
|
||||
"project_id": "test-project",
|
||||
"task_id": self.task_id,
|
||||
"content": "进度怎么样了?",
|
||||
"metadata": {"ui_message_id": ui_id},
|
||||
})
|
||||
|
||||
channel_id = f"session:{self.task_id}"
|
||||
cursor = await self.chat_store._db.execute(
|
||||
"SELECT message_id FROM messages WHERE channel_id = ? AND sender = 'user' ORDER BY timestamp",
|
||||
(channel_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
self.assertEqual([row[0] for row in rows], ["ui-ask-1", "ui-ask-2"])
|
||||
|
||||
async def test_session_send_auto_titles(self) -> None:
|
||||
"""First message should auto-generate title from content."""
|
||||
ws = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user