feat(channel): unify deliverable outbound and WeChat attachments

Require deliverable:true for all channel attachments including images; extend save_deliverable_attachment with attachment_id; add WeChat proactive attachment delivery; inject channel file rules via system prompt instead of repeating on every user message.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-13 15:56:50 +08:00
parent dc5da21f5a
commit cbe68a54cd
14 changed files with 440 additions and 125 deletions

View file

@ -151,15 +151,30 @@ def test_collect_reply_attachments_does_not_reuse_stale_images_on_text_only_repl
assert out == []
def test_collect_recent_tool_attachments_falls_back_to_tool_media() -> None:
def test_collect_recent_tool_attachments_ignores_undeliverable_image_ref() -> None:
rows = [
_Row(role="user", content="draw", attachments=None),
_Row(role="tool", content="{}", attachments='[{"type":"image_ref","attachment_id":"a1"}]'),
_Row(role="assistant", content="x", attachments=None),
]
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
assert out == []
def test_collect_recent_tool_attachments_includes_deliverable_image_ref() -> None:
rows = [
_Row(role="user", content="draw", attachments=None),
_Row(
role="tool",
content="{}",
attachments='[{"type":"image_ref","attachment_id":"a1","mime":"image/png","deliverable":true}]',
),
_Row(role="assistant", content="x", attachments=None),
]
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
assert len(out) == 1
assert out[0].get("attachment_id") == "a1"
assert out[0].get("deliverable") is True
def test_collect_recent_tool_attachments_ignores_media_from_prior_turn() -> None:
@ -280,3 +295,37 @@ def test_maybe_expand_reply_attachments_for_channel_works_for_whatsapp(monkeypat
assert isinstance(out, list) and len(out) == 1
assert out[0].get("data_base64") == base64.b64encode(b"wa").decode("ascii")
def test_maybe_expand_deliverable_xlsx_for_weixin_channel(monkeypatch) -> None:
import base64
class _Meta:
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
name = "report.xlsx"
def _fake_load_bytes(self, attachment_id: str): # noqa: ANN001
assert attachment_id == "x1"
return b"xlsx-bytes", _Meta()
monkeypatch.setattr(
"svc.files.attachment_assets.AttachmentAssetStore.load_bytes",
_fake_load_bytes,
)
r = {
"channel": "weixin",
"attachments": [
{
"type": "binary_ref",
"attachment_id": "x1",
"name": "report.xlsx",
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"deliverable": True,
}
],
}
_maybe_expand_reply_attachments_for_channel(r)
out = r.get("attachments")
assert isinstance(out, list) and len(out) == 1
assert out[0].get("data_base64") == base64.b64encode(b"xlsx-bytes").decode("ascii")
assert out[0].get("name") == "report.xlsx"

View file

@ -925,3 +925,32 @@ def test_tabular_system_hint_uses_configured_preview_and_rows_read(
assert "5000行" in zh_hint
assert "first 30 preview rows" in en_hint
assert "capped at 5000 rows" in en_hint
def test_channel_file_delivery_hint_goes_to_system_not_user_message() -> None:
from runtime.types import StandardMessage
zh_hint = OclawGateway._channel_file_delivery_system_hint("zh")
assert "save_deliverable_attachment" in zh_hint
msg = StandardMessage(
session_id="s1",
tenant_id="t1",
user_id="u1",
role="member",
channel="weixin",
text="hi",
attachments=[],
metadata={},
)
assert OclawGateway._is_channel_delivery_channel(msg)
msg_admin = StandardMessage(
session_id="s1",
tenant_id="t1",
user_id="u1",
role="member",
channel="admin",
text="hi",
attachments=[],
metadata={},
)
assert not OclawGateway._is_channel_delivery_channel(msg_admin)

View file

@ -28,3 +28,28 @@ def test_save_deliverable_attachment_registers_file(tmp_path, monkeypatch) -> No
assert blob is not None
assert blob.decode("utf-8").replace("\r\n", "\n") == "hello deliverable\n"
assert meta is not None
def test_save_deliverable_attachment_marks_existing_attachment_id(tmp_path, monkeypatch) -> None:
from svc.files.attachment_assets import AttachmentAssetStore
store = AttachmentAssetStore(root_dir=tmp_path / "att")
meta = store.save_bytes(b"png-bytes", filename="gen.png", mime="image/png")
monkeypatch.setattr(
"runtime.tools.public.save_deliverable_attachment_tool.AttachmentAssetStore",
lambda root_dir=None: store if root_dir is None else AttachmentAssetStore(root_dir=root_dir),
)
spec = save_deliverable_attachment_tool()
out = spec.handler({"attachment_id": meta.attachment_id})
assert out.get("ok") is True
assert out.get("deliverable") is True
assert out.get("attachment_id") == meta.attachment_id
assert out.get("mime") == "image/png"
def test_save_deliverable_attachment_requires_path_or_attachment_id() -> None:
spec = save_deliverable_attachment_tool()
out = spec.handler({})
assert out.get("ok") is False
assert out.get("error") == "path_or_attachment_id_required"

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from runtime.scheduler.channel_delivery import _encode_weixin_outbound_source, _decode_weixin_outbound_source
from svc.persistence.sqlite_store import SqliteStore
class WeixinAttachmentDeliveryTests(unittest.TestCase):
def test_encode_decode_weixin_outbound_source_with_attachments(self) -> None:
atts = [{"name": "report.xlsx", "mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "data_base64": "abc"}]
raw = _encode_weixin_outbound_source(context_token="ctx-1", attachments=atts, media_path="")
data = _decode_weixin_outbound_source(raw)
self.assertEqual(data.get("context_token"), "ctx-1")
self.assertEqual(data.get("attachments"), atts)
def test_list_pending_weixin_outbound_includes_attachments_from_source(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
db = Path(tmp) / "t.db"
store = SqliteStore(str(db))
source = json.dumps(
{
"kind": "scheduled_job",
"context_token": "ctx-9",
"attachments": [{"name": "a.txt", "data_base64": "dGVzdA=="}],
"media_path": "D:/tmp/a.txt",
},
ensure_ascii=False,
)
store.enqueue_channel_outbound_message(
channel="weixin",
chat_id="wx-user-1",
text="file attached",
tenant_id="tenant-a",
account_id="acct-1",
source=source,
)
items = store.list_pending_weixin_outbound_messages(account_id="acct-1", limit=5)
self.assertEqual(len(items), 1)
self.assertEqual(items[0].get("context_token"), "ctx-9")
self.assertEqual(len(items[0].get("attachments") or []), 1)
self.assertEqual(items[0].get("media_path"), "D:/tmp/a.txt")
if __name__ == "__main__":
unittest.main()

View file

@ -57,3 +57,14 @@ def test_official_runner_supports_reply_attachments_base64() -> None:
assert "data_base64" in text
assert "media_base64" in text
assert "reply.attachments" in text
assert "deliverWeixinReply" in text
assert "buildAttachmentsFromMedia" in text
assert "attachmentNameFromMediaItem" in text
def test_official_runner_proactive_outbound_supports_attachments() -> None:
text = _read("runtime/operations/weixin_bridge/official_runner.ts")
assert "flushWeixinDbOutbound" in text
assert "flushLocalProactiveReplies" in text
assert "item.attachments" in text
assert "item.media_path" in text