mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
Fix WhatsApp image delivery and Chat duplicate attachment display.
Enqueue deliverable tool attachments on the WhatsApp queue path, and dedupe the same attachment_id within one Chat bubble (prefer deliverable). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b6e023cd91
commit
c31625d05b
3 changed files with 128 additions and 6 deletions
|
|
@ -557,14 +557,70 @@ function _foldProcessTextRedundant(processText, collapsedItems) {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a single render of each attachment_id inside one aggregated bubble.
|
||||
* When both generate + save_deliverable_attachment persist the same id, keep the
|
||||
* deliverable row (or the first occurrence if none is marked deliverable).
|
||||
*/
|
||||
function _preferredAttachmentOwnerById(items) {
|
||||
const preferred = new Map();
|
||||
(Array.isArray(items) ? items : []).forEach((it, itemIdx) => {
|
||||
const list = parseAttachments(it && it.attachments);
|
||||
for (const att of list) {
|
||||
if (!att || typeof att !== "object") continue;
|
||||
const aid = String(att.attachment_id || att.attachmentId || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!aid) continue;
|
||||
const prev = preferred.get(aid);
|
||||
if (prev === undefined) {
|
||||
preferred.set(aid, itemIdx);
|
||||
continue;
|
||||
}
|
||||
if (att.deliverable === true) {
|
||||
const prevAtts = parseAttachments((items[prev] && items[prev].attachments) || null);
|
||||
const prevAtt = prevAtts.find((a) => {
|
||||
const id = String((a && (a.attachment_id || a.attachmentId)) || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return id === aid;
|
||||
});
|
||||
if (!(prevAtt && prevAtt.deliverable === true)) preferred.set(aid, itemIdx);
|
||||
}
|
||||
}
|
||||
});
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function _attachmentsForBubbleItem(raw, itemIdx, preferredById) {
|
||||
const list = parseAttachments(raw);
|
||||
if (!list.length) return null;
|
||||
const kept = [];
|
||||
for (const att of list) {
|
||||
if (!att || typeof att !== "object") continue;
|
||||
const aid = String(att.attachment_id || att.attachmentId || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (aid && preferredById instanceof Map) {
|
||||
const owner = preferredById.get(aid);
|
||||
if (owner != null && owner !== itemIdx) continue;
|
||||
}
|
||||
kept.push(att);
|
||||
}
|
||||
return kept.length ? kept : null;
|
||||
}
|
||||
|
||||
async function _buildAggregatedAssistantBubble(tsIso, items) {
|
||||
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
|
||||
const collapsedItems = [];
|
||||
for (const it of items || []) {
|
||||
const preferredAttOwner = _preferredAttachmentOwnerById(items);
|
||||
const itemList = Array.isArray(items) ? items : [];
|
||||
for (let itemIdx = 0; itemIdx < itemList.length; itemIdx++) {
|
||||
const it = itemList[itemIdx];
|
||||
const kind = String((it && it.kind) || "");
|
||||
const text = String((it && it.text) || "");
|
||||
const parsedAtts = parseAttachments(it && it.attachments);
|
||||
const hasInlineAtt = parsedAtts.length > 0;
|
||||
const filteredAtts = _attachmentsForBubbleItem(it && it.attachments, itemIdx, preferredAttOwner);
|
||||
const hasInlineAtt = Array.isArray(filteredAtts) && filteredAtts.length > 0;
|
||||
if (
|
||||
!text.trim() &&
|
||||
!(kind === "tool_result" && hasInlineAtt) &&
|
||||
|
|
@ -582,11 +638,11 @@ async function _buildAggregatedAssistantBubble(tsIso, items) {
|
|||
collapsedItems.push({ title: t("reasoning.processNotes"), text: t0 });
|
||||
}
|
||||
}
|
||||
const attsAssistant = await renderAttachmentsEl(it && it.attachments);
|
||||
const attsAssistant = await renderAttachmentsEl(filteredAtts);
|
||||
if (attsAssistant) inner.appendChild(attsAssistant);
|
||||
} else {
|
||||
_appendAssistantTextSegments(inner, text, collapsedItems);
|
||||
const attsAssistant = await renderAttachmentsEl(it && it.attachments);
|
||||
const attsAssistant = await renderAttachmentsEl(filteredAtts);
|
||||
if (attsAssistant) inner.appendChild(attsAssistant);
|
||||
}
|
||||
} else if (kind === "reasoning") {
|
||||
|
|
@ -597,7 +653,8 @@ async function _buildAggregatedAssistantBubble(tsIso, items) {
|
|||
if (adminChatShowToolOutput) collapsedItems.push({ title: _toolSummaryTitle("tool"), text });
|
||||
// 支持两类附件:base64(image/input_image)和引用型(image_ref)。
|
||||
// image_ref 需要异步拉取 blob,因此改为走 renderAttachmentsEl()。
|
||||
const attsEl = await renderAttachmentsEl(it && it.attachments);
|
||||
// Same attachment_id from generate + save_deliverable is shown once (prefer deliverable).
|
||||
const attsEl = await renderAttachmentsEl(filteredAtts);
|
||||
if (attsEl) inner.appendChild(attsEl);
|
||||
} else {
|
||||
inner.appendChild(el("div", { class: "chat-msg__md", html: renderMarkdownHtml(text) }));
|
||||
|
|
|
|||
|
|
@ -1454,11 +1454,19 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
|
||||
turn_reply = str(turn_result.reply_text or "").strip()
|
||||
# Prefer attachments on the matching assistant text row.
|
||||
# Fallback: deliverable tool media from this user turn
|
||||
# (e.g. save_deliverable_attachment / image generate).
|
||||
turn_atts = _collect_reply_attachments_from_history(
|
||||
store=store,
|
||||
session_id=str(session_id),
|
||||
reply_text=turn_reply,
|
||||
)
|
||||
if not turn_atts:
|
||||
turn_atts = _collect_recent_tool_attachments(
|
||||
store=store,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
except Exception as e:
|
||||
turn_reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
|
||||
turn_atts = []
|
||||
|
|
|
|||
|
|
@ -121,6 +121,63 @@ class WhatsappInboundSerialQueueTests(unittest.TestCase):
|
|||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0].get("text"), "only once")
|
||||
|
||||
def test_whatsapp_queue_includes_deliverable_tool_image(self) -> None:
|
||||
"""Regression (session 8d2b…): image on tool row must still enqueue to WhatsApp.
|
||||
|
||||
Final assistant_text often has no attachments; deliverable lives on
|
||||
``save_deliverable_attachment`` tool_result. Queue path used to skip it.
|
||||
"""
|
||||
reply_text = "done, here is a cute puppy"
|
||||
|
||||
def _handle_turn(**kwargs):
|
||||
msg = kwargs.get("msg")
|
||||
sid = str(getattr(msg, "session_id", "") or "")
|
||||
self.store.add_message(sid, "user", "draw a puppy", event_type="user_text")
|
||||
self.store.add_message(
|
||||
sid,
|
||||
"tool",
|
||||
json.dumps({"ok": True, "attachment_id": "img-puppy", "deliverable": True}),
|
||||
attachments=[
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": "img-puppy",
|
||||
"name": "cute_puppy.png",
|
||||
"mime": "image/png",
|
||||
"deliverable": True,
|
||||
}
|
||||
],
|
||||
event_type="tool_result",
|
||||
)
|
||||
# Final assistant body intentionally has no attachments (real gateway behavior).
|
||||
self.store.add_message(sid, "assistant", reply_text, event_type="assistant_text")
|
||||
return _FakeTurn(reply_text)
|
||||
|
||||
with self._patch_common(), mock.patch("runtime.gateway.OclawGateway") as gw_cls, mock.patch(
|
||||
"runtime.orchestration.group_ingest.should_process_group_inbound", return_value=True
|
||||
), mock.patch(
|
||||
"runtime.application.gateway.whatsapp_inbound_access.handle_whatsapp_access",
|
||||
return_value=None,
|
||||
), mock.patch.object(
|
||||
inbound_mod,
|
||||
"_maybe_expand_reply_attachments_for_channel",
|
||||
# Keep image_ref as-is so we can assert attachment_id without a real asset blob.
|
||||
side_effect=lambda reply: None,
|
||||
):
|
||||
gw = gw_cls.return_value
|
||||
gw.handle_turn.side_effect = lambda **kw: _handle_turn(**kw)
|
||||
out = inbound_mod.process_inbound_payload(self._payload("draw a puppy", stanza="img1"))
|
||||
|
||||
self.assertEqual(out.get("delivery"), "queued")
|
||||
pending = self.store.list_pending_channel_outbound_messages(
|
||||
channel="whatsapp", account_id="wa-default", limit=5
|
||||
)
|
||||
self.assertEqual(len(pending), 1)
|
||||
self.assertEqual(pending[0].get("text"), reply_text)
|
||||
atts = pending[0].get("attachments") or []
|
||||
self.assertTrue(atts, "outbound must include deliverable image attachment")
|
||||
self.assertEqual(atts[0].get("attachment_id"), "img-puppy")
|
||||
self.assertTrue(atts[0].get("deliverable") is True)
|
||||
|
||||
def test_busy_inbound_is_accepted_queued_then_merged(self) -> None:
|
||||
release = threading.Event()
|
||||
seen_texts: list[str] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue