Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/plugin-api-v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 23 additions & 15 deletions channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 60 additions & 2 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -134,25 +142,75 @@ 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,
)

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
Expand Down
Loading