diff --git a/.github/workflows/plugin-api-v2.yml b/.github/workflows/plugin-api-v2.yml index 7c1876b..b30920d 100644 --- a/.github/workflows/plugin-api-v2.yml +++ b/.github/workflows/plugin-api-v2.yml @@ -26,3 +26,22 @@ jobs: env: PYTHONPATH: .plugin-contracts run: python -m akashic_plugin_contracts check plugin.py + + host-channel-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: kachofugetsu09/akashic-agent + ref: 2bf05e320103bf63b67693d114fd2433fd10a0ea + path: .akashic-agent + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install channel contract dependencies + run: python -m pip install -r .akashic-agent/requirements.txt pytest pytest-asyncio + - name: Verify pinned host channel contract + env: + AKASHIC_AGENT_ROOT: ${{ github.workspace }}/.akashic-agent + run: python -m pytest -q tests diff --git a/channel.py b/channel.py index 5583b2f..f465400 100644 --- a/channel.py +++ b/channel.py @@ -23,7 +23,13 @@ import httpx from agent.looping.interrupt import InterruptController -from bus.events import InboundMessage, OutboundMessage +from bus.events import ( + ChannelMessage, + DeliveryReceipt, + InboundMessage, + OutboundMessage, + channel_message_from_outbound, +) from bus.events_lifecycle import ( StreamDeltaReady, ToolCallCompleted, @@ -33,6 +39,7 @@ from bus.queue import MessageBus from infra.channels.base import AttachmentStore, MessageDeduper, SessionIdentityIndex from infra.channels.contract import ChannelContext +from infra.channels.delivery import deliver_message_parts from .cards import ( ToolLiveLine, build_live_card, @@ -174,10 +181,7 @@ async def start(self, ctx: ChannelContext) -> None: self._events_bound = True ctx.push_tool.register_channel( self.name, - text=self.send, - stream_text=self.send_stream, - file=self.send_file, - image=self.send_image, + deliver=self._deliver_message, ) if not self._outbound_bound: ctx.bus.subscribe_outbound(_CHANNEL, self._on_response) @@ -590,23 +594,17 @@ def _record_live_failure(self, session_key: str, err: Exception) -> None: async def _on_response(self, msg: OutboundMessage) -> None: session_key = f"{_CHANNEL}:{msg.chat_id}" - content = msg.content.strip() thinking = self._final_thinking_text(session_key, msg.thinking) tool_lines = self._tool_lines.get(session_key, []) if session_key in self._live_messages: await self._cancel_live_tasks(session_key) # 1. 把实时预览卡原地定格为"过程"卡(思考折叠 + 工具),不撤回 await self._freeze_live_card(session_key, msg.chat_id, thinking, tool_lines) - # 2. 最终结果单独发一条(超长分块、失败降级纯文本) - if content: - for chunk in _split_markdown(content, _CARD_TEXT_LIMIT): - await self._post_card_or_text(msg.chat_id, build_markdown_card(chunk), chunk) + # 2. 通过统一 adapter 提交正文与附件,并让失败继续向上游传播 + receipt = await self._deliver_message(channel_message_from_outbound(msg)) self._clear_live_session(session_key) - for image in (msg.media or []): - try: - await self.send_image(msg.chat_id, image) - except Exception as e: - logger.warning("[feishu] 媒体图片发送失败 chat_id=%s path=%s err=%s", msg.chat_id, image, e) + if not receipt.succeeded: + raise RuntimeError(receipt.detail or "飞书消息提交失败") # 把实时预览卡 PATCH 成过程卡(思考折叠 + 工具时间线);无预览卡但有过程则新发一张。不撤回。 async def _freeze_live_card( @@ -685,6 +683,16 @@ async def send_file( if caption and caption.strip(): await self.send(chat_id, caption) + async def _deliver_message(self, message: ChannelMessage) -> DeliveryReceipt: + """以飞书原生调用提交完整消息并报告部分送达。""" + + return await deliver_message_parts( + message, + send_text=self.send, + send_file=self.send_file, + send_image=self.send_image, + ) + # ── live 任务管理 ────────────────────────────────────────── def _start_live_task(self, session_key: str, coro: Coroutine[Any, Any, None]) -> None: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fac375c..97ed38a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -10,6 +10,14 @@ import pytest +from agent.tools.message_push import MessagePushTool +from bus.events import ( + AttachmentKind, + ChannelAttachment, + ChannelMessage, + DeliveryStatus, +) + def _load_plugin_module(): path = Path(__file__).parents[1] / "plugin.py" @@ -134,13 +142,13 @@ def run_ws_client() -> None: channel._run_ws_client = run_ws_client registry = SimpleNamespace( on=lambda *_args: object(), - register_channel=lambda *_args, **_kwargs: object(), subscribe_outbound=lambda *_args: object(), ) + push_tools = [MessagePushTool(), MessagePushTool()] context = SimpleNamespace( bus=registry, event_bus=registry, - push_tool=registry, + push_tool=push_tools[0], interrupt_controller=None, attachment_store=None, session_manager=None, @@ -148,11 +156,61 @@ def run_ws_client() -> None: await channel.start(context) await channel.stop() + context.push_tool = push_tools[1] await channel.start(context) await channel.stop() assert starts == 2 assert channel._ws_thread is None + assert all("feishu" in tool._adapters for tool in push_tools) + + +@pytest.mark.asyncio +async def test_delivery_adapter_submits_complete_message() -> None: + plugin = FeishuPlugin() + plugin.context = type( + "Ctx", + (), + {"config": FeishuConfigModel(app_id="app", app_secret="secret")}, + )() + channel = plugin.channels()[0] + calls: list[tuple[object, ...]] = [] + + async def send_text(chat_id: str, content: str) -> None: + calls.append(("text", chat_id, content)) + + async def send_file( + chat_id: str, + path: str, + name: str | None = None, + caption: str | None = None, + ) -> None: + calls.append(("file", chat_id, path, name, caption)) + + async def send_image(chat_id: str, path: str) -> None: + calls.append(("image", chat_id, path)) + + channel.send = send_text + channel.send_file = send_file + channel.send_image = send_image + receipt = await channel._deliver_message( + ChannelMessage( + channel="feishu", + chat_id="ou_1", + content="正文", + attachments=( + ChannelAttachment(AttachmentKind.FILE, "/tmp/a.txt", "a.txt"), + ChannelAttachment(AttachmentKind.IMAGE, "/tmp/a.png"), + ), + ) + ) + + assert receipt.status is DeliveryStatus.SUCCESS + assert calls == [ + ("text", "ou_1", "正文"), + ("file", "ou_1", "/tmp/a.txt", "a.txt", None), + ("image", "ou_1", "/tmp/a.png"), + ] @pytest.mark.asyncio