From d7abbd3baa7468480fb655e47d61ee43468ff4d5 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 17 Jul 2026 16:16:53 +0800 Subject: [PATCH] Merge pull request #2963 from AaronZ345/agent/feishu-rich-progress-card feat(feishu): add rich single-card progress --- channel/feishu/feishu_channel.py | 335 +++++++++++++++++++++++++ channel/feishu/feishu_progress_card.py | 204 +++++++++++++++ config-template.json | 1 + config.py | 1 + docs/channels/feishu.mdx | 2 + docs/ja/channels/feishu.mdx | 2 + docs/zh/channels/feishu.mdx | 2 + tests/test_feishu_progress_card.py | 103 ++++++++ 8 files changed, 650 insertions(+) create mode 100644 channel/feishu/feishu_progress_card.py create mode 100644 tests/test_feishu_progress_card.py diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index 7063150f..bb2020fd 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -29,6 +29,7 @@ from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel, check_prefix from channel.feishu.feishu_message import FeishuMessage from channel.feishu.feishu_static_card import build_text_delivery +from channel.feishu.feishu_progress_card import FeishuProgressState from common import utils from common.expired_dict import ExpiredDict from common.log import logger @@ -762,6 +763,340 @@ class FeiShuChanel(ChatChannel): logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}") def _make_feishu_stream_callback(self, context, access_token): + """Route to progress or plain streaming callback based on config. + + feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片 + (_make_feishu_stream_callback_plain)。开启后才使用带状态头、 + Reasoning/Tools 面板与耗时的富进度卡片。 + """ + if conf().get("feishu_progress_card", False): + return self._make_feishu_stream_callback_progress(context, access_token) + return self._make_feishu_stream_callback_plain(context, access_token) + + def _make_feishu_stream_callback_progress(self, context, access_token): + """ + 基于飞书官方"流式更新卡片"API 实现打字机回复。 + + 流程: + 1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片, + 随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去 + 2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content + 传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏 + (流式模式下不受 10 QPS 限制) + 3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板, + 后续 turn 继续复用同一张卡片 + 4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode, + 标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send() + + 前提条件: + - 机器人已开通 cardkit:card:write 权限 + - 飞书客户端 7.20+ + + 失败降级: + - 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel + 走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志 + """ + # 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片; + # reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。 + progress_state = FeishuProgressState() + card_id = [None] + message_id = [None] + # 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update + # 事件各自触发一次创建+发送,导致发出多张卡片。 + init_in_flight = [False] + # 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用 + disabled = [False] + lock = threading.Lock() + + # ---- 异步推送队列 ---------------------------------------------------- + # 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。 + # 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。 + # 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个 + # 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。 + import queue as _queue + push_queue: "_queue.Queue[str | None]" = _queue.Queue() + + def _push_worker(): + while True: + snapshot = push_queue.get() + if snapshot is None: + push_queue.task_done() + return + # 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟 + merged_count = 1 + stop = False + while True: + try: + nxt = push_queue.get_nowait() + except _queue.Empty: + break + merged_count += 1 + if nxt is None: + stop = True + break + snapshot = nxt + try: + _stream_update_text(snapshot) + finally: + for _ in range(merged_count): + push_queue.task_done() + if stop: + return + + push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push") + push_thread.start() + + def _drain_push_queue(): + """等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain, + 否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。""" + try: + push_queue.join() + except Exception: + pass + + msg = context.get("msg") + is_group = context.get("isgroup", False) + receiver = context.get("receiver") + receive_id_type = context.get("receive_id_type", "open_id") + headers = { + "Authorization": "Bearer " + access_token, + "Content-Type": "application/json; charset=utf-8", + } + # 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件 + ELEMENT_ID = "stream_md" + # 操作序号,每次 PUT 必须严格递增(飞书要求) + sequence = [0] + + def _next_sequence(): + with lock: + sequence[0] += 1 + return sequence[0] + + def _build_card_json(): + """Build the initial streaming Card 2.0 payload.""" + with lock: + card = progress_state.build_card(streaming=True) + return json.dumps(card, ensure_ascii=False) + + def _create_and_send_card(): + """同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级""" + try: + # 步骤 1: 创建卡片实体 + create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards" + create_body = {"type": "card_json", "data": _build_card_json()} + res = requests.post( + create_url, headers=headers, json=create_body, timeout=(5, 10) + ) + res_json = res.json() + if res_json.get("code") != 0: + logger.warning( + f"[FeiShu] Stream: create card failed " + f"(code={res_json.get('code')}, msg={res_json.get('msg')}). " + f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。" + f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 " + f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限" + f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。" + ) + with lock: + disabled[0] = True + return + cid = res_json["data"]["card_id"] + with lock: + card_id[0] = cid + + # 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send) + content_payload = json.dumps( + {"type": "card", "data": {"card_id": cid}}, ensure_ascii=False + ) + can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id + if can_reply: + send_url = ( + f"https://open.feishu.cn/open-apis/im/v1/messages/" + f"{msg.msg_id}/reply" + ) + send_body = {"msg_type": "interactive", "content": content_payload} + send_res = requests.post( + send_url, headers=headers, json=send_body, timeout=(5, 10) + ) + else: + send_url = "https://open.feishu.cn/open-apis/im/v1/messages" + params = {"receive_id_type": receive_id_type} + send_body = { + "receive_id": receiver, + "msg_type": "interactive", + "content": content_payload, + } + send_res = requests.post( + send_url, headers=headers, params=params, json=send_body, + timeout=(5, 10), + ) + send_json = send_res.json() + if send_json.get("code") != 0: + logger.warning( + f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。" + ) + with lock: + disabled[0] = True + return + mid = send_json["data"]["message_id"] + with lock: + message_id[0] = mid + logger.info( + f"[FeiShu] Stream: card created and sent, " + f"card_id={cid}, message_id={mid}" + ) + except Exception as e: + logger.warning( + f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。" + ) + with lock: + disabled[0] = True + finally: + with lock: + init_in_flight[0] = False + + def _stream_update_text(full_text): + """PUT 流式更新文本组件。content 必须是当前组件的全量文本。""" + with lock: + cid = card_id[0] + if not cid: + return + url = ( + f"https://open.feishu.cn/open-apis/cardkit/v1/cards/" + f"{cid}/elements/{ELEMENT_ID}/content" + ) + body = { + "content": full_text, + "sequence": _next_sequence(), + } + try: + res = requests.put(url, headers=headers, json=body, timeout=(5, 10)) + res_json = res.json() + if res_json.get("code") != 0: + logger.warning( + f"[FeiShu] Stream: update text failed: {res_json}" + ) + except Exception as e: + logger.warning(f"[FeiShu] Stream: update text exception: {e}") + + def _update_full_card(streaming: bool): + """Refresh panels, header, footer and streaming state in one update.""" + with lock: + cid = card_id[0] + full_card = progress_state.build_card(streaming=streaming) + if not cid: + return + put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}" + put_body = { + "card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)}, + "sequence": _next_sequence(), + } + try: + res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10)) + res_json = res.json() + if res_json.get("code") != 0: + logger.warning( + f"[FeiShu] Stream: full card update failed: {res_json}" + ) + except Exception as e: + logger.warning( + f"[FeiShu] Stream: full card update exception: {e}" + ) + + def on_event(event: dict): + event_type = event.get("type") + data = event.get("data", {}) + + # 一旦降级,本次回复不再做任何流式操作 + with lock: + if disabled[0]: + return + + if event_type == "agent_start": + with lock: + progress_state.consume(event) + if card_id[0] is None and not init_in_flight[0]: + init_in_flight[0] = True + _create_and_send_card() + return + + if event_type in ("turn_start", "reasoning_update"): + with lock: + progress_state.consume(event) + return + + if event_type == "message_update": + delta = data.get("delta", "") + if not delta: + return + + # 第一段:判断是否需要初始化(创建卡片 + 发送) + need_init = False + with lock: + if card_id[0] is None and not init_in_flight[0]: + init_in_flight[0] = True + need_init = True + + if need_init: + _create_and_send_card() + # 初始化失败已标记 disabled,下次循环直接 return + with lock: + if disabled[0]: + return + + # 第二段:累加文本,把快照丢给 push worker 异步推送。 + # 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk + # (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。 + snapshot = "" + should_push = False + with lock: + progress_state.consume(event) + if card_id[0]: + snapshot = progress_state.current_text + should_push = True + + if should_push: + push_queue.put(snapshot) + + elif event_type == "message_end": + # 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。 + tool_calls = data.get("tool_calls", []) or [] + with lock: + progress_state.consume(event) + if not tool_calls: + return + _drain_push_queue() + _update_full_card(streaming=True) + + elif event_type == "agent_cancelled": + with lock: + progress_state.consume(event) + + elif event_type == "agent_end": + # Finalize the same card with a status header and elapsed footer. + with lock: + progress_state.consume(event) + final_text = progress_state.current_text + has_card = card_id[0] is not None + init_busy = init_in_flight[0] + context["feishu_streamed"] = True + + if not has_card and not init_busy: + with lock: + init_in_flight[0] = True + _create_and_send_card() + with lock: + if disabled[0]: + return + + _drain_push_queue() + _stream_update_text(final_text) + _update_full_card(streaming=False) + push_queue.put(None) + + return on_event + + def _make_feishu_stream_callback_plain(self, context, access_token): """ 基于飞书官方"流式更新卡片"API 实现打字机回复。 diff --git a/channel/feishu/feishu_progress_card.py b/channel/feishu/feishu_progress_card.py new file mode 100644 index 00000000..88116c75 --- /dev/null +++ b/channel/feishu/feishu_progress_card.py @@ -0,0 +1,204 @@ +"""State and Card 2.0 rendering for a Feishu agent run.""" + +from __future__ import annotations + +import time +from typing import Any, Dict, List, Optional + + +_MAX_PANEL_STEPS = 10 +_MAX_STEP_CHARS = 800 + + +class FeishuProgressState: + """Reduce CowAgent stream events into one renderable Feishu card state.""" + + def __init__(self, started_at: Optional[float] = None): + self.started_at = time.monotonic() if started_at is None else started_at + self.status = "running" + self.turns = 0 + self.current_text = "" + self._reasoning_buffer = "" + self.reasoning_steps: List[str] = [] + self.tool_steps: List[Dict[str, str]] = [] + self.cancelled = False + + def consume(self, event: Dict[str, Any]) -> None: + """Consume one event emitted by ``AgentStreamHandler``.""" + event_type = event.get("type") + data = event.get("data") or {} + + if event_type == "turn_start": + self._mark_running_tools_done() + turn = data.get("turn") + if isinstance(turn, int): + self.turns = max(self.turns, turn) + else: + self.turns += 1 + if self.turns > 1: + self.current_text = "" + return + + if event_type == "reasoning_update": + self._reasoning_buffer += str(data.get("delta") or "") + return + + if event_type == "message_update": + self.current_text += str(data.get("delta") or "") + return + + if event_type == "message_end": + self._commit_reasoning() + for tool_call in data.get("tool_calls") or []: + self.tool_steps.append( + { + "summary": _format_tool_call(tool_call), + "status": "running", + } + ) + return + + if event_type == "agent_cancelled": + self.cancelled = True + self.status = "stopped" + return + + if event_type == "agent_end": + self._commit_reasoning() + self._mark_running_tools_done() + cancelled = self.cancelled or bool(data.get("cancelled")) + if cancelled: + self.status = "stopped" + self.current_text = self.current_text.rstrip() or "_(stopped)_" + else: + self.status = "done" + final_response = data.get("final_response") + if final_response: + self.current_text = str(final_response) + + def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]: + """Render the current state as a Feishu Card 2.0 object.""" + title, template = { + "running": ("Working", "blue"), + "done": ("Done", "green"), + "stopped": ("Stopped", "grey"), + "error": ("Error", "red"), + }.get(self.status, ("Working", "blue")) + + main_text = self.current_text or "..." + elements: List[Dict[str, Any]] = [] + + if self.reasoning_steps: + elements.append( + _panel( + "Reasoning ({})".format(len(self.reasoning_steps)), + [_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]], + expanded=streaming, + ) + ) + elif streaming: + elements.append( + _panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True) + ) + + if self.tool_steps: + elements.append( + _panel( + "Tools ({})".format(len(self.tool_steps)), + [ + _text_row("{} · {}".format(step["summary"], step["status"])) + for step in self.tool_steps[-_MAX_PANEL_STEPS:] + ], + expanded=streaming, + ) + ) + + elements.append( + { + "tag": "markdown", + "element_id": "stream_md", + "content": main_text, + } + ) + + elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at) + turn_label = "turn" if self.turns == 1 else "turns" + elements.extend( + [ + {"tag": "hr"}, + { + "tag": "markdown", + "content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label), + "text_size": "notation", + }, + ] + ) + + config: Dict[str, Any] = { + "streaming_mode": streaming, + "update_multi": True, + "enable_forward_interaction": True, + "summary": {"content": _summary(main_text, title)}, + } + if streaming: + config["streaming_config"] = { + "print_frequency_ms": {"default": 40}, + "print_step": {"default": 4}, + "print_strategy": "fast", + } + + return { + "schema": "2.0", + "config": config, + "header": { + "template": template, + "title": {"tag": "plain_text", "content": title}, + }, + "body": {"elements": elements}, + } + + def _commit_reasoning(self) -> None: + reasoning = self._reasoning_buffer.strip() + if reasoning: + self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:]) + self._reasoning_buffer = "" + + def _mark_running_tools_done(self) -> None: + for step in self.tool_steps: + if step["status"] == "running": + step["status"] = "done" + + +def _format_tool_call(tool_call: Dict[str, Any]) -> str: + # Tool arguments can contain user data or credentials. The progress card + # only needs an activity label, so never echo raw arguments into chat. + return str(tool_call.get("name") or "tool") + + +def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]: + return { + "tag": "collapsible_panel", + "expanded": expanded, + "background_color": "grey", + "header": {"title": {"tag": "plain_text", "content": title}}, + "border": {"color": "grey"}, + "vertical_spacing": "8px", + "padding": "4px 8px", + "elements": elements, + } + + +def _text_row(content: str, muted: bool = False) -> Dict[str, Any]: + text = { + "tag": "plain_text", + "content": content, + "text_size": "notation", + } + if muted: + text["text_color"] = "grey" + return {"tag": "div", "text": text} + + +def _summary(text: str, fallback: str) -> str: + preview = " ".join(text.strip().split()) + return preview[:60] or fallback diff --git a/config-template.json b/config-template.json index bd24bbb5..aae25be7 100644 --- a/config-template.json +++ b/config-template.json @@ -29,6 +29,7 @@ "feishu_app_secret": "", "feishu_stream_reply": true, "feishu_markdown_card": true, + "feishu_progress_card": false, "dingtalk_client_id": "", "dingtalk_client_secret": "", "wecom_bot_id": "", diff --git a/config.py b/config.py index 5e25e7ee..78c79106 100644 --- a/config.py +++ b/config.py @@ -179,6 +179,7 @@ available_setting = { # Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+) "feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients "feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure + "feishu_progress_card": False, # render normal chat streaming as a rich progress card (status header, reasoning/tool panels, elapsed time); off by default keeps the plain typewriter card # DingTalk config "dingtalk_client_id": "", # DingTalk bot Client ID "dingtalk_client_secret": "", # DingTalk bot Client Secret diff --git a/docs/channels/feishu.mdx b/docs/channels/feishu.mdx index d14adb8a..2b5acccf 100644 --- a/docs/channels/feishu.mdx +++ b/docs/channels/feishu.mdx @@ -75,6 +75,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_secret` | Feishu app App Secret | - | | `feishu_stream_reply` | Enable streaming typewriter reply | `true` | | `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` | + | `feishu_progress_card` | Use a rich progress card (status header, reasoning/tool panels, elapsed time) for normal chat streaming; off keeps the plain typewriter card | `false` | @@ -101,6 +102,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | Voice messages | ✅ send/receive | | Streaming reply | ✅ (powered by Feishu cardkit streaming card) | | Markdown card | ✅ for non-streaming and scheduled replies | +| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) | Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically. diff --git a/docs/ja/channels/feishu.mdx b/docs/ja/channels/feishu.mdx index 5695d3ec..c4727708 100644 --- a/docs/ja/channels/feishu.mdx +++ b/docs/ja/channels/feishu.mdx @@ -72,6 +72,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_secret` | Feishu アプリの App Secret | - | | `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` | | `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` | + | `feishu_progress_card` | 通常会話のストリーミングをリッチ進捗カード(ステータス、推論/ツールパネル、経過時間)で表示。無効時は通常のタイプライターカード | `false` | @@ -98,6 +99,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | 音声メッセージ | ✅ 送受信 | | ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) | | Markdown カード | ✅ 非ストリーミング応答と定期配信 | +| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) | ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。 diff --git a/docs/zh/channels/feishu.mdx b/docs/zh/channels/feishu.mdx index bf8c5cd8..9ae9d18d 100644 --- a/docs/zh/channels/feishu.mdx +++ b/docs/zh/channels/feishu.mdx @@ -76,6 +76,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | `feishu_app_secret` | 飞书应用 App Secret | - | | `feishu_stream_reply` | 是否开启流式打字机回复 | `true` | | `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` | + | `feishu_progress_card` | 普通对话流式回复是否使用富进度卡片(状态头、思考/工具面板、耗时);关闭时用普通打字机卡片 | `false` | @@ -102,6 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p | 语音消息 | ✅ 收发 | | 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) | | Markdown 卡片 | ✅ 非流式回复与定时推送 | +| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) | 流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。 diff --git a/tests/test_feishu_progress_card.py b/tests/test_feishu_progress_card.py new file mode 100644 index 00000000..91e76ba7 --- /dev/null +++ b/tests/test_feishu_progress_card.py @@ -0,0 +1,103 @@ +from channel.feishu.feishu_progress_card import FeishuProgressState + + +def _panels(card): + return [ + element + for element in card["body"]["elements"] + if element.get("tag") == "collapsible_panel" + ] + + +def test_running_card_has_status_reasoning_lane_and_stream_target(): + state = FeishuProgressState(started_at=100.0) + + card = state.build_card(streaming=True, now=102.0) + + assert card["header"]["template"] == "blue" + assert card["header"]["title"]["content"] == "Working" + assert card["config"]["streaming_mode"] is True + assert _panels(card)[0]["header"]["title"]["content"] == "Reasoning" + stream = next( + element + for element in card["body"]["elements"] + if element.get("element_id") == "stream_md" + ) + assert stream["content"] == "..." + + +def test_tool_turn_populates_reasoning_and_tools_lanes(): + state = FeishuProgressState(started_at=100.0) + state.consume({"type": "turn_start", "data": {"turn": 1}}) + state.consume({"type": "reasoning_update", "data": {"delta": "Need the latest files."}}) + state.consume({"type": "message_update", "data": {"delta": "Checking now."}}) + state.consume( + { + "type": "message_end", + "data": { + "tool_calls": [ + {"name": "read_file", "arguments": {"path": "README.md"}} + ] + }, + } + ) + + card = state.build_card(streaming=True, now=103.0) + panels = _panels(card) + assert [panel["header"]["title"]["content"] for panel in panels] == [ + "Reasoning (1)", + "Tools (1)", + ] + assert panels[0]["elements"][0]["text"]["content"] == "Need the latest files." + assert "read_file" in panels[1]["elements"][0]["text"]["content"] + assert "running" in panels[1]["elements"][0]["text"]["content"] + + state.consume({"type": "turn_start", "data": {"turn": 2}}) + done_card = state.build_card(streaming=True, now=104.0) + assert "done" in _panels(done_card)[1]["elements"][0]["text"]["content"] + + +def test_agent_end_finalizes_status_body_and_footer(): + state = FeishuProgressState(started_at=100.0) + state.consume({"type": "turn_start", "data": {"turn": 1}}) + state.consume( + { + "type": "agent_end", + "data": {"final_response": "**Finished**", "cancelled": False}, + } + ) + + card = state.build_card(streaming=False, now=105.2) + + assert card["header"]["template"] == "green" + assert card["header"]["title"]["content"] == "Done" + assert card["config"]["streaming_mode"] is False + assert next( + element["content"] + for element in card["body"]["elements"] + if element.get("element_id") == "stream_md" + ) == "**Finished**" + footer = card["body"]["elements"][-1] + assert footer["text_size"] == "notation" + assert footer["content"] == "5.2s · 1 turn" + + +def test_cancelled_agent_uses_partial_output_and_stopped_status(): + state = FeishuProgressState(started_at=100.0) + state.consume({"type": "message_update", "data": {"delta": "partial"}}) + state.consume({"type": "agent_cancelled", "data": {}}) + state.consume( + { + "type": "agent_end", + "data": {"final_response": "stale response", "cancelled": True}, + } + ) + + card = state.build_card(streaming=False, now=101.0) + + assert card["header"]["title"]["content"] == "Stopped" + assert next( + element["content"] + for element in card["body"]["elements"] + if element.get("element_id") == "stream_md" + ) == "partial"