mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 04:40:45 +08:00
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:
parent
dc5da21f5a
commit
cbe68a54cd
14 changed files with 440 additions and 125 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
49
tests/test_weixin_attachment_delivery.py
Normal file
49
tests/test_weixin_attachment_delivery.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue