mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 03:30:48 +08:00
feat(channel): deliver generated files on WhatsApp and honor user tool policy
Add save_deliverable_attachment for explicit document outbound, pass channel user_id into tool risk gating so write_file/run_command respect per-user allow_high settings, and document the workflow in channel-file-delivery skill. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
61d54b10fd
commit
19f4d285ce
9 changed files with 346 additions and 37 deletions
|
|
@ -74,19 +74,29 @@ def _resolve_channel_dispatch(store: Any, *, channel: str, account: dict[str, An
|
||||||
return interaction_mode, specialist, lang
|
return interaction_mode, specialist, lang
|
||||||
|
|
||||||
|
|
||||||
def _build_admin_gateway_executor(store: Any, *, tenant_id: str, specialist: str, session_id: str, lang: str) -> Any:
|
def _build_admin_gateway_executor(
|
||||||
|
store: Any,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
viewer_username: str | None = None,
|
||||||
|
specialist: str,
|
||||||
|
session_id: str,
|
||||||
|
lang: str,
|
||||||
|
) -> Any:
|
||||||
from runtime.agents.factory import build_gateway_executor
|
from runtime.agents.factory import build_gateway_executor
|
||||||
|
|
||||||
|
uid = str(user_id or "").strip() or None
|
||||||
return build_gateway_executor(
|
return build_gateway_executor(
|
||||||
store,
|
store,
|
||||||
lang=str(lang or "zh"),
|
lang=str(lang or "zh"),
|
||||||
specialist=specialist,
|
specialist=specialist,
|
||||||
viewer_user_id=None,
|
viewer_user_id=uid,
|
||||||
viewer_username="administrator",
|
viewer_username=str(viewer_username or "administrator").strip() or "administrator",
|
||||||
viewer_tenant_id=(tenant_id or None),
|
viewer_tenant_id=(tenant_id or None),
|
||||||
policy_session_id=session_id,
|
policy_session_id=session_id,
|
||||||
path_policy_tenant_id=(tenant_id or None),
|
path_policy_tenant_id=(tenant_id or None),
|
||||||
path_policy_user_id=None,
|
path_policy_user_id=uid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -559,13 +569,25 @@ def _rows_since_last_user_message(rows: list[Any]) -> list[Any]:
|
||||||
_CHANNEL_DELIVERABLE_ATTACHMENT_TYPES = frozenset(
|
_CHANNEL_DELIVERABLE_ATTACHMENT_TYPES = frozenset(
|
||||||
{"image_ref", "video_ref", "image", "input_image", "image_url"}
|
{"image_ref", "video_ref", "image", "input_image", "image_url"}
|
||||||
)
|
)
|
||||||
|
_CHANNEL_EXPLICIT_DELIVERABLE_TYPES = frozenset({"binary_ref", "text_ref"})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_channel_deliverable_attachment(att: dict[str, Any]) -> bool:
|
||||||
|
if not isinstance(att, dict):
|
||||||
|
return False
|
||||||
|
t = str(att.get("type") or "").strip().lower()
|
||||||
|
if t in _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES:
|
||||||
|
return True
|
||||||
|
if t in _CHANNEL_EXPLICIT_DELIVERABLE_TYPES:
|
||||||
|
return att.get("deliverable") is True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dict[str, Any]]:
|
||||||
"""Fallback for channel delivery: reuse tool media produced during the current user turn only.
|
"""Fallback for channel delivery: reuse tool media produced during the current user turn only.
|
||||||
|
|
||||||
Avoids re-sending images from earlier conversation turns when the latest assistant row has no attachments.
|
Avoids re-sending images from earlier conversation turns when the latest assistant row has no attachments.
|
||||||
Only visual outbound media is eligible — not text_ref/binary_ref from read/query tools.
|
Visual media is always eligible; documents need explicit deliverable=true on the attachment ref.
|
||||||
"""
|
"""
|
||||||
sid = str(session_id or "").strip()
|
sid = str(session_id or "").strip()
|
||||||
if not sid:
|
if not sid:
|
||||||
|
|
@ -582,16 +604,13 @@ def _collect_recent_tool_attachments(*, store: Any, session_id: str) -> list[dic
|
||||||
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
atts = _parse_message_attachments(getattr(row, "attachments", None))
|
||||||
if not atts:
|
if not atts:
|
||||||
continue
|
continue
|
||||||
ok = False
|
|
||||||
deliverable: list[dict[str, Any]] = []
|
deliverable: list[dict[str, Any]] = []
|
||||||
for a in atts:
|
for a in atts:
|
||||||
if not isinstance(a, dict):
|
if not isinstance(a, dict):
|
||||||
continue
|
continue
|
||||||
t = str(a.get("type") or "").strip().lower()
|
if _is_channel_deliverable_attachment(a):
|
||||||
if t in _CHANNEL_DELIVERABLE_ATTACHMENT_TYPES:
|
|
||||||
ok = True
|
|
||||||
deliverable.append(a)
|
deliverable.append(a)
|
||||||
if ok:
|
if deliverable:
|
||||||
return deliverable
|
return deliverable
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -1064,6 +1083,11 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
if dispatch_lang in {"zh", "en"}
|
if dispatch_lang in {"zh", "en"}
|
||||||
else resolve_runtime_lang(store=store, user_text=user_text)
|
else resolve_runtime_lang(store=store, user_text=user_text)
|
||||||
)
|
)
|
||||||
|
if str(inbound.channel or "").strip().lower() in {"whatsapp", "wechat", "weixin"}:
|
||||||
|
from runtime.orchestration.group_ingest import build_channel_file_delivery_instruction
|
||||||
|
|
||||||
|
ch_hint = build_channel_file_delivery_instruction(lang=lang)
|
||||||
|
user_text = f"{ch_hint}\n{user_text}" if user_text else ch_hint
|
||||||
gw = OclawGateway(store=store)
|
gw = OclawGateway(store=store)
|
||||||
msg = StandardMessage(
|
msg = StandardMessage(
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
|
|
@ -1090,6 +1114,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
manager = _build_admin_gateway_executor(
|
manager = _build_admin_gateway_executor(
|
||||||
store,
|
store,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
specialist="generalist",
|
specialist="generalist",
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
lang=lang,
|
lang=lang,
|
||||||
|
|
@ -1097,6 +1122,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
specialist_factory = lambda sid: _build_admin_gateway_executor(
|
specialist_factory = lambda sid: _build_admin_gateway_executor(
|
||||||
store,
|
store,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
specialist=sid,
|
specialist=sid,
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
lang=lang,
|
lang=lang,
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,19 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
return []
|
return []
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
|
root_deliverable = result.get("deliverable") is True
|
||||||
|
|
||||||
|
def _with_deliverable(item: dict[str, Any], src: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
if root_deliverable or (isinstance(src, dict) and src.get("deliverable") is True):
|
||||||
|
item["deliverable"] = True
|
||||||
|
return item
|
||||||
|
|
||||||
aid = str(result.get("attachment_id") or "").strip()
|
aid = str(result.get("attachment_id") or "").strip()
|
||||||
root_mime = str(result.get("mime") or "").strip()
|
root_mime = str(result.get("mime") or "").strip()
|
||||||
if aid:
|
if aid:
|
||||||
ref_type = _ref_type_for_mime(root_mime)
|
ref_type = _ref_type_for_mime(root_mime)
|
||||||
out.append(
|
out.append(
|
||||||
|
_with_deliverable(
|
||||||
{
|
{
|
||||||
"type": ref_type,
|
"type": ref_type,
|
||||||
"attachment_id": aid,
|
"attachment_id": aid,
|
||||||
|
|
@ -72,7 +80,9 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
"bytes": result.get("bytes"),
|
"bytes": result.get("bytes"),
|
||||||
"width": result.get("width"),
|
"width": result.get("width"),
|
||||||
"height": result.get("height"),
|
"height": result.get("height"),
|
||||||
}
|
},
|
||||||
|
result,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
refs = result.get("attachments")
|
refs = result.get("attachments")
|
||||||
if isinstance(refs, list):
|
if isinstance(refs, list):
|
||||||
|
|
@ -100,6 +110,7 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
if r_typ not in {"image_ref", "video_ref", "text_ref", "binary_ref"}:
|
if r_typ not in {"image_ref", "video_ref", "text_ref", "binary_ref"}:
|
||||||
r_typ = _ref_type_for_mime(r_mime)
|
r_typ = _ref_type_for_mime(r_mime)
|
||||||
out.append(
|
out.append(
|
||||||
|
_with_deliverable(
|
||||||
{
|
{
|
||||||
"type": r_typ,
|
"type": r_typ,
|
||||||
"attachment_id": r_aid,
|
"attachment_id": r_aid,
|
||||||
|
|
@ -108,7 +119,9 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
"bytes": r.get("bytes"),
|
"bytes": r.get("bytes"),
|
||||||
"width": r.get("width"),
|
"width": r.get("width"),
|
||||||
"height": r.get("height"),
|
"height": r.get("height"),
|
||||||
}
|
},
|
||||||
|
r,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
inner = result.get("result")
|
inner = result.get("result")
|
||||||
if isinstance(inner, dict):
|
if isinstance(inner, dict):
|
||||||
|
|
@ -122,6 +135,7 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
a_id = str(item.get("attachment_id") or "").strip()
|
a_id = str(item.get("attachment_id") or "").strip()
|
||||||
if a_id:
|
if a_id:
|
||||||
out.append(
|
out.append(
|
||||||
|
_with_deliverable(
|
||||||
{
|
{
|
||||||
"type": typ,
|
"type": typ,
|
||||||
"attachment_id": a_id,
|
"attachment_id": a_id,
|
||||||
|
|
@ -130,7 +144,9 @@ def _attachments_from_tool_result(result: Any) -> list[dict[str, Any]]:
|
||||||
"bytes": item.get("bytes"),
|
"bytes": item.get("bytes"),
|
||||||
"width": item.get("width"),
|
"width": item.get("width"),
|
||||||
"height": item.get("height"),
|
"height": item.get("height"),
|
||||||
}
|
},
|
||||||
|
item,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif typ == "image_url":
|
elif typ == "image_url":
|
||||||
src = str(item.get("url") or item.get("image_url") or "").strip()
|
src = str(item.get("url") or item.get("image_url") or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,20 @@ def build_group_focus_instruction(*, lang: str = "zh") -> str:
|
||||||
return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]"
|
return "[群聊规则:只回答当前发言人的问题;除非本条消息明确引用或承接前文,否则不要默认继承其他群成员的上下文。]"
|
||||||
|
|
||||||
|
|
||||||
|
def build_channel_file_delivery_instruction(*, lang: str = "zh") -> str:
|
||||||
|
if str(lang or "").strip().lower().startswith("en"):
|
||||||
|
return (
|
||||||
|
"[Channel rule: to send a generated file back to the user on WhatsApp/WeChat, "
|
||||||
|
"call save_deliverable_attachment after creating the file. write_file or run_command alone "
|
||||||
|
"does not attach files to the outbound message.]"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"[渠道规则:若要把生成的文件发回用户(WhatsApp/微信),"
|
||||||
|
"在 write_file 或 run_command 生成文件后必须调用 save_deliverable_attachment;"
|
||||||
|
"仅 write_file 不会随消息发送附件。]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_whatsapp_group_reply_metadata(
|
def build_whatsapp_group_reply_metadata(
|
||||||
*,
|
*,
|
||||||
inbound: Any,
|
inbound: Any,
|
||||||
|
|
@ -400,6 +414,7 @@ def build_whatsapp_group_reply_metadata(
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GROUP_SESSION_USER_SENTINEL",
|
"GROUP_SESSION_USER_SENTINEL",
|
||||||
"GroupPolicyConfig",
|
"GroupPolicyConfig",
|
||||||
|
"build_channel_file_delivery_instruction",
|
||||||
"build_group_sender_context",
|
"build_group_sender_context",
|
||||||
"build_group_focus_instruction",
|
"build_group_focus_instruction",
|
||||||
"build_group_quoted_context_block",
|
"build_group_quoted_context_block",
|
||||||
|
|
|
||||||
81
runtime/tools/public/save_deliverable_attachment_tool.py
Normal file
81
runtime/tools/public/save_deliverable_attachment_tool.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from runtime.tools.base import ToolSpec
|
||||||
|
from runtime.tools.path_guard import resolve_workspace_path
|
||||||
|
from svc.files.attachment_assets import AttachmentAssetStore
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_mime(path: Path, override: str) -> str:
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
guessed, _ = mimetypes.guess_type(str(path))
|
||||||
|
return guessed or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def save_deliverable_attachment_tool() -> ToolSpec:
|
||||||
|
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw = str(args.get("path") or "").strip().strip('"').strip("'")
|
||||||
|
if not raw:
|
||||||
|
return {"ok": False, "error": "path_required"}
|
||||||
|
display_name = str(args.get("name") or "").strip()
|
||||||
|
mime_override = str(args.get("mime") or "").strip()
|
||||||
|
try:
|
||||||
|
p = resolve_workspace_path(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
if not p.exists() or not p.is_file():
|
||||||
|
return {"ok": False, "error": "file_not_found", "path": str(p)}
|
||||||
|
filename = display_name or p.name
|
||||||
|
mime = _guess_mime(p, mime_override)
|
||||||
|
try:
|
||||||
|
data = p.read_bytes()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": "read_failed", "detail": str(exc)}
|
||||||
|
meta = AttachmentAssetStore().save_bytes(data, filename=filename, mime=mime)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"attachment_id": meta.attachment_id,
|
||||||
|
"name": meta.name,
|
||||||
|
"mime": meta.mime,
|
||||||
|
"bytes": meta.bytes,
|
||||||
|
"deliverable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="save_deliverable_attachment",
|
||||||
|
description=(
|
||||||
|
"Register a workspace file for outbound channel delivery (WhatsApp/WeChat). "
|
||||||
|
"Call this after generating a file with write_file or run_command when the user should receive it as an attachment. "
|
||||||
|
"write_file alone does not send files to messaging channels."
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Workspace file path (relative to workspace root or allowed absolute path).",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional download filename shown to the user.",
|
||||||
|
},
|
||||||
|
"mime": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional MIME type override (e.g. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=_handler,
|
||||||
|
tags=frozenset({"public", "workspace", "attachment", "channel"}),
|
||||||
|
read_only=False,
|
||||||
|
risk_level="low",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["save_deliverable_attachment_tool"]
|
||||||
51
skills/_workspace/public/channel-file-delivery/SKILL.md
Normal file
51
skills/_workspace/public/channel-file-delivery/SKILL.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
name: channel-file-delivery
|
||||||
|
description: "在 WhatsApp/微信等渠道会话中,把生成的文件作为附件发回用户的流程。适用于:导出 Excel/CSV/TXT、run_command 生成报告后需要让用户在聊天里收到文件。"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 渠道文件发送 — Channel File Delivery
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
用户在 **WhatsApp / 微信** 等渠道对话中,要求你生成并**把文件发给他**(不仅是文字说明路径)。
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
1. **`write_file` / `run_command` 不会自动发送附件**
|
||||||
|
文件只会落在 workspace 磁盘上,渠道出站看不到。
|
||||||
|
|
||||||
|
2. **必须调用 `save_deliverable_attachment`**
|
||||||
|
在文件生成完成后,用该工具把 workspace 文件注册到 attachment store,并标记 `deliverable`。
|
||||||
|
|
||||||
|
3. **再用简短文字回复**
|
||||||
|
说明文件名与要点;系统会把标记为 deliverable 的附件随本条回复发到渠道。
|
||||||
|
|
||||||
|
## 推荐流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. run_command 或 write_file → 生成 data/workspace/tmp/report.xlsx
|
||||||
|
2. save_deliverable_attachment(path="data/workspace/tmp/report.xlsx", name="report.xlsx")
|
||||||
|
3. 文字回复:「已附上 report.xlsx,共 N 行…」
|
||||||
|
```
|
||||||
|
|
||||||
|
## Excel 示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户:帮我把统计结果导出 Excel 发我
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. run_command:用 pandas 写入 data/workspace/tmp/summary.xlsx
|
||||||
|
2. save_deliverable_attachment(path="data/workspace/tmp/summary.xlsx")
|
||||||
|
3. 回复摘要(行数、主要结论)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 限制
|
||||||
|
|
||||||
|
- 每轮回复通常只发送**第一个**附件(约 8MB 上限)。
|
||||||
|
- 超大文件应压缩、抽样或只发摘要。
|
||||||
|
- 用户**上传**的文件不要用 `save_deliverable_attachment` 回传;那是分析输入,不是生成输出。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- 路径规范见 `path-convention` skill(`data/workspace/`)。
|
||||||
|
- 分析用户上传的表格用 `query_tabular_attachment` 等读工具,与出站发送无关。
|
||||||
44
tests/test_channel_tool_policy.py
Normal file
44
tests/test_channel_tool_policy.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from runtime.tools.catalog import materialize_tool_specs
|
||||||
|
from runtime.tools.public_registry import clear_public_tool_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_tools_respects_user_allow_high_risk_public_tools(monkeypatch) -> None:
|
||||||
|
clear_public_tool_cache()
|
||||||
|
monkeypatch.delenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", raising=False)
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get_user_workspace_path_allowlist(self, *, tenant_id: str, user_id: str):
|
||||||
|
assert tenant_id == "tenant-a"
|
||||||
|
assert user_id == "user-a"
|
||||||
|
return {"allow_high_risk_public_tools": True}
|
||||||
|
|
||||||
|
names = {
|
||||||
|
t.name
|
||||||
|
for t in materialize_tool_specs(
|
||||||
|
store=_Store(),
|
||||||
|
path_policy_tenant_id="tenant-a",
|
||||||
|
path_policy_user_id="user-a",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert "write_file" in names
|
||||||
|
assert "run_command" in names
|
||||||
|
assert "save_deliverable_attachment" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_tools_blocks_high_risk_without_user_policy(monkeypatch) -> None:
|
||||||
|
clear_public_tool_cache()
|
||||||
|
monkeypatch.delenv("AIA_PUBLIC_TOOLS_ALLOW_HIGH", raising=False)
|
||||||
|
|
||||||
|
names = {
|
||||||
|
t.name
|
||||||
|
for t in materialize_tool_specs(
|
||||||
|
store=None,
|
||||||
|
path_policy_tenant_id="tenant-a",
|
||||||
|
path_policy_user_id=None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert "write_file" not in names
|
||||||
|
assert "run_command" not in names
|
||||||
|
assert "save_deliverable_attachment" in names
|
||||||
|
|
@ -188,6 +188,37 @@ def test_collect_recent_tool_attachments_ignores_text_ref_from_lookup_tools() ->
|
||||||
assert out == []
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_recent_tool_attachments_includes_deliverable_binary_ref() -> None:
|
||||||
|
rows = [
|
||||||
|
_Row(role="user", content="export", attachments=None),
|
||||||
|
_Row(
|
||||||
|
role="tool",
|
||||||
|
content="{}",
|
||||||
|
attachments='[{"type":"binary_ref","attachment_id":"gen-xlsx","name":"report.xlsx","mime":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","deliverable":true}]',
|
||||||
|
),
|
||||||
|
_Row(role="assistant", content="done", attachments=None),
|
||||||
|
]
|
||||||
|
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].get("attachment_id") == "gen-xlsx"
|
||||||
|
assert out[0].get("deliverable") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_recent_tool_attachments_includes_deliverable_text_ref() -> None:
|
||||||
|
rows = [
|
||||||
|
_Row(role="user", content="export", attachments=None),
|
||||||
|
_Row(
|
||||||
|
role="tool",
|
||||||
|
content="{}",
|
||||||
|
attachments='[{"type":"text_ref","attachment_id":"gen-txt","name":"out.txt","mime":"text/plain","deliverable":true}]',
|
||||||
|
),
|
||||||
|
_Row(role="assistant", content="done", attachments=None),
|
||||||
|
]
|
||||||
|
out = _collect_recent_tool_attachments(store=_FakeStore(rows), session_id="s1")
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0].get("attachment_id") == "gen-txt"
|
||||||
|
|
||||||
|
|
||||||
def test_maybe_add_media_path_for_wechat_reply_sets_media_path(monkeypatch) -> None:
|
def test_maybe_add_media_path_for_wechat_reply_sets_media_path(monkeypatch) -> None:
|
||||||
# Avoid touching disk: stub AttachmentAssetStore.get_local_path.
|
# Avoid touching disk: stub AttachmentAssetStore.get_local_path.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
30
tests/test_save_deliverable_attachment_tool.py
Normal file
30
tests/test_save_deliverable_attachment_tool.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from runtime.tools.public.save_deliverable_attachment_tool import save_deliverable_attachment_tool
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_deliverable_attachment_registers_file(tmp_path, monkeypatch) -> None:
|
||||||
|
from svc.files.attachment_assets import AttachmentAssetStore
|
||||||
|
|
||||||
|
store = AttachmentAssetStore(root_dir=tmp_path / "att")
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"runtime.tools.public.save_deliverable_attachment_tool.resolve_workspace_path",
|
||||||
|
lambda raw: tmp_path / "report.txt",
|
||||||
|
)
|
||||||
|
report = tmp_path / "report.txt"
|
||||||
|
report.write_text("hello deliverable\n", encoding="utf-8")
|
||||||
|
|
||||||
|
spec = save_deliverable_attachment_tool()
|
||||||
|
out = spec.handler({"path": "report.txt"})
|
||||||
|
assert out.get("ok") is True
|
||||||
|
assert out.get("deliverable") is True
|
||||||
|
assert out.get("attachment_id")
|
||||||
|
assert out.get("mime") == "text/plain"
|
||||||
|
blob, meta = store.load_bytes(str(out["attachment_id"]))
|
||||||
|
assert blob is not None
|
||||||
|
assert blob.decode("utf-8").replace("\r\n", "\n") == "hello deliverable\n"
|
||||||
|
assert meta is not None
|
||||||
|
|
@ -3,6 +3,21 @@ from __future__ import annotations
|
||||||
from runtime.chat.tool_runtime import _attachments_from_tool_result
|
from runtime.chat.tool_runtime import _attachments_from_tool_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachments_from_tool_result_preserves_deliverable_flag() -> None:
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"attachment_id": "att-doc-1",
|
||||||
|
"mime": "text/plain",
|
||||||
|
"name": "out.txt",
|
||||||
|
"bytes": 12,
|
||||||
|
"deliverable": True,
|
||||||
|
}
|
||||||
|
out = _attachments_from_tool_result(result)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["type"] == "text_ref"
|
||||||
|
assert out[0].get("deliverable") is True
|
||||||
|
|
||||||
|
|
||||||
def test_attachments_from_tool_result_preserves_non_image_ref_types() -> None:
|
def test_attachments_from_tool_result_preserves_non_image_ref_types() -> None:
|
||||||
result = {
|
result = {
|
||||||
"attachments": [
|
"attachments": [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue