From 2e0a9d04d9a5e0d21a3b5090d9b7fb3cd261f70d Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 18 Mar 2026 16:10:12 +0800 Subject: [PATCH 1/7] fix: stabilize forwarded message timestamps --- channels/feishu_channel.py | 4 +++- channels/telegram_channel.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 9e971c6..4027dfb 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -546,7 +546,9 @@ def _format_forwarded_prompt(self, original_content: str, forwarded: dict) -> st parts.append(f"转发自: {sender_name}") if forwarded.get("timestamp"): - ts = datetime.fromtimestamp(forwarded["timestamp"], tz=timezone(timedelta(hours=8))) + ts = datetime.fromtimestamp( + forwarded["timestamp"], tz=timezone(timedelta(hours=8)) + ) parts.append(f"时间: {ts.strftime('%Y-%m-%d %H:%M')}") parts.append("\n--- 转发内容 ---") diff --git a/channels/telegram_channel.py b/channels/telegram_channel.py index d4a1c78..d81ee8e 100644 --- a/channels/telegram_channel.py +++ b/channels/telegram_channel.py @@ -325,7 +325,9 @@ def _format_forwarded_text(self, text: str, update: "Update") -> str: # 添加时间戳 if msg.forward_date: - ts = datetime.fromtimestamp(msg.forward_date, tz=timezone(timedelta(hours=8))) + ts = datetime.fromtimestamp( + msg.forward_date, tz=timezone(timedelta(hours=8)) + ) parts.append(f"时间: {ts.strftime('%Y-%m-%d %H:%M')}") parts.append("\n--- 转发内容 ---") From e814b5050bb834b43f91b3c9747d7e9334b7e6ee Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 18 Mar 2026 16:12:44 +0800 Subject: [PATCH 2/7] fix lint --- channels/feishu_channel.py | 4 +--- channels/telegram_channel.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 4027dfb..9e971c6 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -546,9 +546,7 @@ def _format_forwarded_prompt(self, original_content: str, forwarded: dict) -> st parts.append(f"转发自: {sender_name}") if forwarded.get("timestamp"): - ts = datetime.fromtimestamp( - forwarded["timestamp"], tz=timezone(timedelta(hours=8)) - ) + ts = datetime.fromtimestamp(forwarded["timestamp"], tz=timezone(timedelta(hours=8))) parts.append(f"时间: {ts.strftime('%Y-%m-%d %H:%M')}") parts.append("\n--- 转发内容 ---") diff --git a/channels/telegram_channel.py b/channels/telegram_channel.py index d81ee8e..d4a1c78 100644 --- a/channels/telegram_channel.py +++ b/channels/telegram_channel.py @@ -325,9 +325,7 @@ def _format_forwarded_text(self, text: str, update: "Update") -> str: # 添加时间戳 if msg.forward_date: - ts = datetime.fromtimestamp( - msg.forward_date, tz=timezone(timedelta(hours=8)) - ) + ts = datetime.fromtimestamp(msg.forward_date, tz=timezone(timedelta(hours=8))) parts.append(f"时间: {ts.strftime('%Y-%m-%d %H:%M')}") parts.append("\n--- 转发内容 ---") From 375b0271a698ad0775d2aee40dc935e71d497aca Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 18 Mar 2026 19:18:05 +0800 Subject: [PATCH 3/7] Simplify Feishu result cards --- channels/feishu_channel.py | 248 +++++++++++++++++++------ docs/todo.md | 5 + tests/test_feishu_message_rendering.py | 128 +++++++++++++ 3 files changed, 320 insertions(+), 61 deletions(-) create mode 100644 tests/test_feishu_message_rendering.py diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 9e971c6..7672b3c 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -17,7 +17,7 @@ import threading from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from taskboard_bus import Channel, MessageBus, OutboundMessage, OutboundMessageType @@ -210,6 +210,13 @@ def send(self, msg: OutboundMessage) -> None: ] content = error_text + card = self._build_notification_card( + task_id=task_id, + task=task, + is_completed=is_completed, + body_text=content, + ) + # Try to reply in thread if we have an origin message with self._origin_lock: origin = self._task_origin.get(task_id) @@ -220,13 +227,13 @@ def send(self, msg: OutboundMessage) -> None: # Add emoji reaction to the message that triggered the task (or resume) emoji = "DONE" if is_completed else "Cry" self._add_reaction(reaction_msg_id, emoji) - sent_id = self._reply_message(root_msg_id, content) + sent_id = self._reply_message(root_msg_id, content, card=card) # Fallback: send to default chat if no origin or reply failed if not sent_id: chat_id = self.db.get_setting("feishu_default_chat_id") if chat_id: - sent_id = self._send_message(chat_id, content) + sent_id = self._send_message(chat_id, content, card=card, fallback_content=content) if sent_id: print(f"[Feishu] Notification sent successfully, message_id: {sent_id}") @@ -246,8 +253,14 @@ def _on_outbound(self, msg: OutboundMessage) -> None: # ── outbound: low-level send ────────────────────────────────── - def _send_message(self, chat_id: str, content: str) -> Optional[str]: - """Send a markdown card to chat_id. Returns the sent message_id or None.""" + def _send_message( + self, + chat_id: str, + content: str, + card: Optional[dict[str, Any]] = None, + fallback_content: Optional[str] = None, + ) -> Optional[str]: + """Send a card to chat_id. Falls back to the legacy markdown card on failure.""" print(f"[Feishu] _send_message called, chat_id: {chat_id}, content length: {len(content)}") if not self._client: print("[Feishu] Client not initialized in _send_message") @@ -255,35 +268,24 @@ def _send_message(self, chat_id: str, content: str) -> Optional[str]: try: receive_id_type = "chat_id" if chat_id.startswith("oc_") else "open_id" print(f"[Feishu] receive_id_type: {receive_id_type}") - card = { - "config": {"wide_screen_mode": True}, - "elements": [{"tag": "markdown", "content": content}], - } - print("[Feishu] Building CreateMessageRequest...") - request = ( - CreateMessageRequest.builder() - .receive_id_type(receive_id_type) - .request_body( - CreateMessageRequestBody.builder() - .receive_id(chat_id) - .msg_type("interactive") - .content(json.dumps(card, ensure_ascii=False)) - .build() - ) - .build() + card_payload = card or self._build_legacy_markdown_card(content) + message_id = self._create_message( + receive_id_type=receive_id_type, + chat_id=chat_id, + card=card_payload, ) - print("[Feishu] Calling im.v1.message.create()...") - response = self._client.im.v1.message.create(request) - print( - f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}" - ) - if response.success(): - message_id = response.data.message_id - print(f"[Feishu] Message sent successfully, message_id: {message_id}") + if message_id: return message_id - else: - print(f"[Feishu] Send failed: {response.code} {response.msg}") - return None + + if card is not None: + legacy_content = fallback_content or content + print("[Feishu] Structured card send failed, retrying with legacy markdown card") + return self._create_message( + receive_id_type=receive_id_type, + chat_id=chat_id, + card=self._build_legacy_markdown_card(legacy_content), + ) + return None except Exception as e: print(f"[Feishu] Error sending message: {e}") import traceback @@ -291,8 +293,13 @@ def _send_message(self, chat_id: str, content: str) -> Optional[str]: traceback.print_exc() return None - def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: - """Reply to a specific message (thread-style). Returns the sent message_id or None.""" + def _reply_message( + self, + parent_message_id: str, + content: str, + card: Optional[dict[str, Any]] = None, + ) -> Optional[str]: + """Reply to a specific message (thread-style). Falls back to the legacy markdown card.""" print( f"[Feishu] _reply_message called, parent_message_id: {parent_message_id}, content length: {len(content)}" ) @@ -300,34 +307,18 @@ def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: print("[Feishu] Client not initialized in _reply_message") return None try: - card = { - "config": {"wide_screen_mode": True}, - "elements": [{"tag": "markdown", "content": content}], - } - request = ( - ReplyMessageRequest.builder() - .message_id(parent_message_id) - .request_body( - ReplyMessageRequestBody.builder() - .msg_type("interactive") - .content(json.dumps(card, ensure_ascii=False)) - .reply_in_thread(True) - .build() - ) - .build() - ) - print("[Feishu] Calling im.v1.message.reply()...") - response = self._client.im.v1.message.reply(request) - print( - f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}" - ) - if response.success(): - message_id = response.data.message_id - print(f"[Feishu] Reply sent successfully, message_id: {message_id}") + reply_card = card or self._build_legacy_markdown_card(content) + message_id = self._create_reply(parent_message_id=parent_message_id, card=reply_card) + if message_id: return message_id - else: - print(f"[Feishu] Reply failed: {response.code} {response.msg}") - return None + + if card is not None: + print("[Feishu] Structured card reply failed, retrying with legacy markdown card") + return self._create_reply( + parent_message_id=parent_message_id, + card=self._build_legacy_markdown_card(content), + ) + return None except Exception as e: print(f"[Feishu] Error replying to message: {e}") import traceback @@ -335,6 +326,141 @@ def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: traceback.print_exc() return None + def _create_message(self, receive_id_type: str, chat_id: str, card: dict[str, Any]) -> Optional[str]: + print("[Feishu] Building CreateMessageRequest...") + request = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("interactive") + .content(json.dumps(card, ensure_ascii=False)) + .build() + ) + .build() + ) + print("[Feishu] Calling im.v1.message.create()...") + response = self._client.im.v1.message.create(request) + print( + f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}" + ) + if response.success(): + message_id = response.data.message_id + print(f"[Feishu] Message sent successfully, message_id: {message_id}") + return message_id + + print(f"[Feishu] Send failed: {response.code} {response.msg}") + return None + + def _create_reply(self, parent_message_id: str, card: dict[str, Any]) -> Optional[str]: + request = ( + ReplyMessageRequest.builder() + .message_id(parent_message_id) + .request_body( + ReplyMessageRequestBody.builder() + .msg_type("interactive") + .content(json.dumps(card, ensure_ascii=False)) + .reply_in_thread(True) + .build() + ) + .build() + ) + print("[Feishu] Calling im.v1.message.reply()...") + response = self._client.im.v1.message.reply(request) + print( + f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}" + ) + if response.success(): + message_id = response.data.message_id + print(f"[Feishu] Reply sent successfully, message_id: {message_id}") + return message_id + + print(f"[Feishu] Reply failed: {response.code} {response.msg}") + return None + + def _build_notification_card( + self, + task_id: int, + task: dict[str, Any], + is_completed: bool, + body_text: str, + ) -> dict[str, Any]: + clean_body = (body_text or "").strip() or ("Done." if is_completed else "Unknown error") + summary = self._truncate_text(clean_body.splitlines()[0], 120) if clean_body else "" + elements = self._build_result_elements(body_text=clean_body) + + if not is_completed: + elements.append( + { + "tag": "markdown", + "content": f"`/status {task_id}` for full details", + } + ) + + return { + "schema": "2.0", + "config": { + "wide_screen_mode": True, + "enable_forward": True, + "width_mode": "fill", + "summary": {"content": summary}, + }, + "body": { + "elements": elements, + }, + } + + def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]: + clean_body = (body_text or "").strip() or "Done." + if len(clean_body) <= 1200: + return [ + { + "tag": "markdown", + "content": clean_body, + } + ] + + preview = self._truncate_text(clean_body, 500) + full_text = self._truncate_text(clean_body, 8000) + return [ + { + "tag": "markdown", + "content": preview, + }, + { + "tag": "collapsible_panel", + "expanded": False, + "header": { + "title": { + "tag": "plain_text", + "content": "展开查看完整结果", + } + }, + "elements": [ + { + "tag": "markdown", + "content": full_text, + } + ], + }, + ] + + def _build_legacy_markdown_card(self, content: str) -> dict[str, Any]: + return { + "config": {"wide_screen_mode": True}, + "elements": [{"tag": "markdown", "content": content}], + } + + def _truncate_text(self, text: str, limit: int) -> str: + normalized = text.replace("\r\n", "\n").strip() + if len(normalized) <= limit: + return normalized + return normalized[:limit].rstrip() + "\n…(truncated)" + + def _escape_feishu_markdown(self, text: str) -> str: + return text.replace("\\", "\\\\") + def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP"): """Add an emoji reaction in a background thread (non-blocking).""" if not self._client or not FEISHU_AVAILABLE: diff --git a/docs/todo.md b/docs/todo.md index c1ac1e3..4b319c2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,6 +2,11 @@ ## In Progress +- [ ] **修复 Feishu 默认通知接收人 `open_id cross app`** — 排查默认通知 fallback 使用 `open_id` 发送时的跨应用问题,并补充更安全的接收人解析与提示文案 +- [x] **优化 Feishu 结果展示** — 参考 `agentara` 的消息渲染实现,梳理当前 Feishu 输出格式并改进可读性与结构化展示 + - ✅ 已完成:`channels/feishu_channel.py` 改为生成更简洁的结构化 Feishu 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容 + - ✅ 已完成:发送/回复链路增加 legacy markdown 卡片回退,避免新卡片不兼容时通知直接失败 + - ✅ 验证:`uv run pytest tests/test_feishu_message_rendering.py tests/test_feishu_forwarded_messages.py -q` 通过,`19 passed` - [x] **修复转发消息时间格式测试失败** — 统一 Feishu / Telegram 转发时间按北京时间 `UTC+8` 格式化 - ✅ 已修复:`channels/feishu_channel.py` 与 `channels/telegram_channel.py` 不再依赖进程本地时区 - ✅ 验证:相关 3 个失败用例通过;`tests/test_feishu_forwarded_messages.py` 和 `tests/test_telegram_forwarded_messages.py` 全部通过 diff --git a/tests/test_feishu_message_rendering.py b/tests/test_feishu_message_rendering.py new file mode 100644 index 0000000..91809b3 --- /dev/null +++ b/tests/test_feishu_message_rendering.py @@ -0,0 +1,128 @@ +""" +Tests for Feishu outbound message card rendering. +""" + +from unittest.mock import Mock, patch + +import pytest + +from taskboard_bus import OutboundMessage, OutboundMessageType + + +@pytest.fixture +def mock_feishu_channel(): + """Create a FeishuChannel instance with mocked dependencies.""" + with patch("channels.feishu_channel.FEISHU_AVAILABLE", True): + from channels.feishu_channel import FeishuChannel + + bus = Mock() + db = Mock() + scheduler = Mock() + + channel = FeishuChannel(bus, db, scheduler) + channel._client = Mock() + return channel + + +class TestFeishuNotificationCards: + def test_build_completed_card_shows_result_only(self, mock_feishu_channel): + task = { + "id": 42, + "title": "Fix Feishu rendering", + "prompt": "请优化飞书结果展示,让内容更容易扫读。", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + result_text = "已完成优化。\n\n- 增加摘要\n- 增加结果预览\n- 增加折叠面板" + + card = mock_feishu_channel._build_notification_card( + task_id=42, + task=task, + is_completed=True, + body_text=result_text, + ) + + assert card["schema"] == "2.0" + assert card["config"]["summary"]["content"] == "已完成优化。" + assert len(card["body"]["elements"]) == 1 + assert any( + element.get("tag") == "markdown" + and "增加摘要" in element.get("content", "") + for element in card["body"]["elements"] + ) + assert not any( + "Task #42" in element.get("content", "") or "Prompt" in element.get("content", "") + for element in card["body"]["elements"] + if isinstance(element, dict) + ) + + def test_build_failed_card_adds_status_hint(self, mock_feishu_channel): + task = { + "id": 7, + "title": "Broken task", + "prompt": "调试失败任务", + "agent": "claude", + "working_dir": "~/workspace/agentforge", + } + + card = mock_feishu_channel._build_notification_card( + task_id=7, + task=task, + is_completed=False, + body_text="Traceback: something went wrong", + ) + + assert any( + element.get("tag") == "markdown" + and "/status 7" in element.get("content", "") + for element in card["body"]["elements"] + ) + + def test_build_completed_card_wraps_long_body_in_collapsible_panel(self, mock_feishu_channel): + task = { + "id": 99, + "title": "Long output task", + "prompt": "输出一份很长的结果", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + long_text = "A" * 2200 + + card = mock_feishu_channel._build_notification_card( + task_id=99, + task=task, + is_completed=True, + body_text=long_text, + ) + + assert any(element.get("tag") == "collapsible_panel" for element in card["body"]["elements"]) + assert any( + element.get("tag") == "markdown" and "AAAA" in element.get("content", "") + for element in card["body"]["elements"] + ) + + def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channel): + task = { + "id": 5, + "title": "Ship update", + "prompt": "发布更新", + "result": "done", + "error": None, + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + mock_feishu_channel.db.get_task.return_value = task + mock_feishu_channel.db.get_setting.return_value = "oc_test_chat" + mock_feishu_channel._send_message = Mock(return_value="msg_123") + + msg = OutboundMessage( + type=OutboundMessageType.TASK_COMPLETED, + task_id=5, + payload={"result": "done", "title": "Ship update"}, + ) + + mock_feishu_channel.send(msg) + + _, kwargs = mock_feishu_channel._send_message.call_args + assert kwargs["fallback_content"] == "done" + assert kwargs["card"]["schema"] == "2.0" From a2b7ab69ff31edf3c9ec2eee2f7936cccd849f52 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 18 Mar 2026 19:24:59 +0800 Subject: [PATCH 4/7] fix lint --- .claude/settings.local.json | 13 ++++++++++++- channels/feishu_channel.py | 4 +++- tests/test_feishu_message_rendering.py | 10 +++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e620ee8..82f4662 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,18 @@ { "permissions": { "allow": [ - "Bash(codex exec:*)" + "Bash(codex exec:*)", + "Bash(defuddle parse:*)", + "Bash(curl -s \"https://raw.githubusercontent.com/hetaoBackend/agentara/main/user-home/.claude/skills/daily-hunt/SKILL.md\")", + "Bash(curl:*)", + "Bash(agent-browser --version)", + "Bash(agent-browser open:*)", + "Bash(agent-browser snapshot:*)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); c=d[''''current_condition''''][0]; print\\(c[''''weatherDesc''''][0][''''value''''], c[''''temp_C'''']+''''°C'''', c[''''humidity'''']+''''%''''\\)\")", + "Bash(grep -v \"^$\\\\|^\\\\s*$\")", + "WebFetch(domain:wttr.in)", + "WebFetch(domain:richerculture.cn)", + "Skill(agent-browser)" ] } } diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 7672b3c..5b7db8c 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -326,7 +326,9 @@ def _reply_message( traceback.print_exc() return None - def _create_message(self, receive_id_type: str, chat_id: str, card: dict[str, Any]) -> Optional[str]: + def _create_message( + self, receive_id_type: str, chat_id: str, card: dict[str, Any] + ) -> Optional[str]: print("[Feishu] Building CreateMessageRequest...") request = ( CreateMessageRequest.builder() diff --git a/tests/test_feishu_message_rendering.py b/tests/test_feishu_message_rendering.py index 91809b3..da1b228 100644 --- a/tests/test_feishu_message_rendering.py +++ b/tests/test_feishu_message_rendering.py @@ -46,8 +46,7 @@ def test_build_completed_card_shows_result_only(self, mock_feishu_channel): assert card["config"]["summary"]["content"] == "已完成优化。" assert len(card["body"]["elements"]) == 1 assert any( - element.get("tag") == "markdown" - and "增加摘要" in element.get("content", "") + element.get("tag") == "markdown" and "增加摘要" in element.get("content", "") for element in card["body"]["elements"] ) assert not any( @@ -73,8 +72,7 @@ def test_build_failed_card_adds_status_hint(self, mock_feishu_channel): ) assert any( - element.get("tag") == "markdown" - and "/status 7" in element.get("content", "") + element.get("tag") == "markdown" and "/status 7" in element.get("content", "") for element in card["body"]["elements"] ) @@ -95,7 +93,9 @@ def test_build_completed_card_wraps_long_body_in_collapsible_panel(self, mock_fe body_text=long_text, ) - assert any(element.get("tag") == "collapsible_panel" for element in card["body"]["elements"]) + assert any( + element.get("tag") == "collapsible_panel" for element in card["body"]["elements"] + ) assert any( element.get("tag") == "markdown" and "AAAA" in element.get("content", "") for element in card["body"]["elements"] From 337d18379b69726f46e6d0ed412a2cb4edae8373 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 19 Mar 2026 16:56:29 +0800 Subject: [PATCH 5/7] Rebuild bundled backend before packaging --- Makefile | 4 ++-- docs/todo.md | 6 ++++++ taskboard-electron/package.json | 2 ++ taskboard-electron/scripts/build-backend.mjs | 22 ++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 taskboard-electron/scripts/build-backend.mjs diff --git a/Makefile b/Makefile index 0985ae7..f1c7d4b 100644 --- a/Makefile +++ b/Makefile @@ -37,12 +37,12 @@ build-backend: build-electron: @echo "构建Electron应用..." - cd $(ELECTRON_DIR) && npm run package + cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run package @echo "Electron应用构建完成" package-dmg: build-backend build-electron @echo "打包DMG文件..." - cd $(ELECTRON_DIR) && npm run make + cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run make @if [ -f "$(DMG_OUTPUT)" ]; then \ echo "DMG文件生成成功: $(DMG_OUTPUT)"; \ ls -lh "$(DMG_OUTPUT)"; \ diff --git a/docs/todo.md b/docs/todo.md index 4b319c2..0ecddf0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -174,3 +174,9 @@ - 测试验证:Python 进程 PID 变化确认热重载工作正常 - 预计工作量:2-3小时(实际完成时间:约30分钟) - 难度评估:⭐⭐⭐☆☆(中等偏易) + +- [x] **修复 macOS App 创建任务的 Feishu 卡片通知** — 排查 UI 创建任务的结果通知是否绕过当前 Feishu 渲染链路,并统一到优化后的卡片发送逻辑 + - ✅ 已完成:Electron `package/make` 现在会在打包前自动重建 `taskboard-electron/resources/taskboard`,避免 macOS App 继续携带旧的 Python backend + - ✅ 已完成:新增 `taskboard-electron/scripts/build-backend.mjs`,统一复用 `make build-backend` 构建 bundled backend;`Makefile` 同步避免重复构建 + - ✅ 验证:`uv run pytest tests/test_feishu_message_rendering.py tests/test_feishu_forwarded_messages.py -q` 通过,`19 passed` + - ✅ 验证:`node taskboard-electron/scripts/build-backend.mjs` 通过,并生成新的 `taskboard-electron/resources/taskboard` diff --git a/taskboard-electron/package.json b/taskboard-electron/package.json index 4cf3a08..8271d64 100644 --- a/taskboard-electron/package.json +++ b/taskboard-electron/package.json @@ -6,6 +6,8 @@ "main": ".vite/build/main.js", "private": true, "scripts": { + "prepackage": "node scripts/build-backend.mjs", + "premake": "node scripts/build-backend.mjs", "start": "electron-forge start", "package": "electron-forge package", "make": "electron-forge make", diff --git a/taskboard-electron/scripts/build-backend.mjs b/taskboard-electron/scripts/build-backend.mjs new file mode 100644 index 0000000..bc19f8a --- /dev/null +++ b/taskboard-electron/scripts/build-backend.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.env.SKIP_BACKEND_BUILD === "1") { + console.log("[build-backend] SKIP_BACKEND_BUILD=1, skipping bundled backend rebuild"); + process.exit(0); +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDir, "..", ".."); + +console.log("[build-backend] Rebuilding bundled Python backend..."); + +const result = spawnSync("make", ["build-backend"], { + cwd: projectRoot, + stdio: "inherit", +}); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} From 56c4a35f534a376abbae90a7c17a7fade6ec5ca5 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 20 Mar 2026 09:15:18 +0800 Subject: [PATCH 6/7] Fix scheduled_at timezone handling and Feishu card UX --- channels/feishu_channel.py | 39 +++++--- docs/todo.md | 13 +++ taskboard-electron/src/renderer/App.jsx | 38 +++++--- taskboard-electron/src/renderer/dateTime.mjs | 94 ++++++++++++++++++ .../src/renderer/dateTime.test.mjs | 32 ++++++ taskboard.py | 75 +++++++++++--- tests/test_feishu_message_rendering.py | 97 ++++++++++++++++++- tests/test_scheduler_timezones.py | 96 ++++++++++++++++++ 8 files changed, 434 insertions(+), 50 deletions(-) create mode 100644 taskboard-electron/src/renderer/dateTime.mjs create mode 100644 taskboard-electron/src/renderer/dateTime.test.mjs create mode 100644 tests/test_scheduler_timezones.py diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 5b7db8c..8a4bfcc 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -65,6 +65,11 @@ • 回复任意结果通知即可继续对话。 """ +FEISHU_RESULT_PREVIEW_LIMIT = 500 +FEISHU_INLINE_RESULT_LIMIT = 1200 +FEISHU_CARD_MARKDOWN_CHUNK = 7000 +FEISHU_FALLBACK_MARKDOWN_LIMIT = 8000 + class FeishuChannel(Channel): """Feishu/Lark channel integration using WebSocket long-connection.""" @@ -201,8 +206,6 @@ def send(self, msg: OutboundMessage) -> None: if is_completed: result_text = (msg.payload.get("result") or task.get("result") or "").strip() - if len(result_text) > 10000: - result_text = result_text[:10000] + "\n…(truncated)" content = result_text or "Done." else: error_text = (msg.payload.get("error") or task.get("error") or "Unknown error").strip()[ @@ -233,7 +236,12 @@ def send(self, msg: OutboundMessage) -> None: if not sent_id: chat_id = self.db.get_setting("feishu_default_chat_id") if chat_id: - sent_id = self._send_message(chat_id, content, card=card, fallback_content=content) + sent_id = self._send_message( + chat_id, + content, + card=card, + fallback_content=self._truncate_text(content, FEISHU_FALLBACK_MARKDOWN_LIMIT), + ) if sent_id: print(f"[Feishu] Notification sent successfully, message_id: {sent_id}") @@ -415,7 +423,7 @@ def _build_notification_card( def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]: clean_body = (body_text or "").strip() or "Done." - if len(clean_body) <= 1200: + if len(clean_body) <= FEISHU_INLINE_RESULT_LIMIT: return [ { "tag": "markdown", @@ -423,13 +431,11 @@ def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]: } ] - preview = self._truncate_text(clean_body, 500) - full_text = self._truncate_text(clean_body, 8000) + panel_elements = [ + {"tag": "markdown", "content": chunk} + for chunk in self._chunk_text(clean_body, FEISHU_CARD_MARKDOWN_CHUNK) + ] return [ - { - "tag": "markdown", - "content": preview, - }, { "tag": "collapsible_panel", "expanded": False, @@ -439,12 +445,7 @@ def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]: "content": "展开查看完整结果", } }, - "elements": [ - { - "tag": "markdown", - "content": full_text, - } - ], + "elements": panel_elements, }, ] @@ -460,6 +461,12 @@ def _truncate_text(self, text: str, limit: int) -> str: return normalized return normalized[:limit].rstrip() + "\n…(truncated)" + def _chunk_text(self, text: str, limit: int) -> list[str]: + normalized = text.replace("\r\n", "\n") + if not normalized: + return [""] + return [normalized[i : i + limit] for i in range(0, len(normalized), limit)] + def _escape_feishu_markdown(self, text: str) -> str: return text.replace("\\", "\\\\") diff --git a/docs/todo.md b/docs/todo.md index 0ecddf0..db2c810 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,6 +2,19 @@ ## In Progress +- [x] **修复 Feishu 卡片展开后仍显示 truncated 内容** — 排查折叠面板完整结果的组装逻辑,确保展开后显示完整正文而不是再次裁剪后的剩余片段 + - ✅ 已修复:长结果卡片改为“折叠态仅显示 summary,展开态显示完整正文”,不再在面板外额外渲染截断预览 + - ✅ 已修复:展开区按 chunk 承载完整正文,避免 `truncated` 出现在展开内容里,也避免预览与正文重复阅读 + - ✅ 验证:`pytest -q tests/test_feishu_message_rendering.py` 通过,`7 passed` +- [ ] **起草前后端大文件拆分 RFC** — 基于 `taskboard.py` 与 `taskboard-electron/src/renderer/App.jsx` 的现状,设计可渐进落地的重构方案、模块边界与迁移节奏 +- [x] **修复 macOS App 中 scheduled_at 时间未按系统时区展示** — 统一 `scheduled_at` 在前端列表展示、详情展示、编辑弹窗回填的本地时区格式,避免 `toISOString()` 导致的 UTC 偏移 + - ✅ 已修复:新增 renderer 时间工具,统一解析 offset-aware/naive 时间;`scheduled_at` 编辑弹窗回填不再走 `toISOString()` + - ✅ 已修复:任务卡片、任务详情、heartbeat 面板和事件时间展示统一走本地时区格式化 + - ✅ 验证:`TZ=Asia/Shanghai node --test taskboard-electron/src/renderer/dateTime.test.mjs` 通过;`cd taskboard-electron && npx vite build --config vite.renderer.config.mjs` 通过 +- [x] **修复 scheduler 的 naive/aware datetime 比较崩溃** — 统一 `scheduled_at` 的 `next_run_at` 存储格式,并兼容旧数据中的 offset-aware ISO 时间,避免 scheduler tick 抛出 `can't compare offset-naive and offset-aware datetimes` + - ✅ 已修复:后端会把 offset-aware 的 `next_run_at` 归一化为本地 naive 时间存储,`get_due_tasks()` / `get_due_heartbeats()` 改为 Python 层按规范化后的 datetime 判断 due + - ✅ 已修复:scheduler tick 与 heartbeat dedupe cooldown 都兼容旧数据里的 offset-aware ISO 时间,不再触发 naive/aware 比较异常 + - ✅ 验证:`uv run pytest -q` 通过,`61 passed`(存在既有 warning:Telegram 测试里有一个未 awaited coroutine) - [ ] **修复 Feishu 默认通知接收人 `open_id cross app`** — 排查默认通知 fallback 使用 `open_id` 发送时的跨应用问题,并补充更安全的接收人解析与提示文案 - [x] **优化 Feishu 结果展示** — 参考 `agentara` 的消息渲染实现,梳理当前 Feishu 输出格式并改进可读性与结构化展示 - ✅ 已完成:`channels/feishu_channel.py` 改为生成更简洁的结构化 Feishu 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容 diff --git a/taskboard-electron/src/renderer/App.jsx b/taskboard-electron/src/renderer/App.jsx index b0cc8f2..4216250 100644 --- a/taskboard-electron/src/renderer/App.jsx +++ b/taskboard-electron/src/renderer/App.jsx @@ -1,4 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { + formatDateTimeLocalInput, + formatTaskDateTime, + formatTaskTime, + parseTaskDateTime, + serializeDateTimeLocalInput, +} from "./dateTime.mjs"; const API = "http://127.0.0.1:9712/api"; @@ -611,7 +618,7 @@ function TaskCard({ task, onAction, onViewDetail }) { ⏳ {task.delay_seconds}s )} {task.schedule_type === "scheduled_at" && task.next_run_at && ( - 📅 {new Date(task.next_run_at).toLocaleString()} + 📅 {formatTaskDateTime(task.next_run_at)} )} {task.schedule_type === "cron" && ( ⏲ {task.cron_expr} @@ -639,7 +646,7 @@ function TaskCard({ task, onAction, onViewDetail }) { {task.run_count > 0 && (
Runs: {task.run_count}{task.max_runs ? ` / ${task.max_runs}` : ""} - {task.last_run_at && ` · Last: ${new Date(task.last_run_at).toLocaleTimeString()}`} + {task.last_run_at && ` · Last: ${formatTaskTime(task.last_run_at)}`}
)} @@ -948,9 +955,9 @@ function HeartbeatCard({ heartbeat, onAction, onViewDetail }) {
- Next: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"} + Next: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"} {" · "} - Triggered: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"} + Triggered: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}
{heartbeat.last_error && (
@@ -1037,9 +1044,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) { {heartbeat.last_decision && {heartbeat.last_decision}}
-
Next run: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"}
-
Last tick: {heartbeat.last_tick_at ? new Date(heartbeat.last_tick_at).toLocaleString() : "never"}
-
Last trigger: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"}
+
Next run: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"}
+
Last tick: {heartbeat.last_tick_at ? formatTaskDateTime(heartbeat.last_tick_at) : "never"}
+
Last trigger: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}
Cooldown: {heartbeat.cooldown_seconds || 0}s
@@ -1083,7 +1090,7 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
setSelectedTickId(tick.id)} style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 6 }}>
{tick.decision_type || tick.status}
- {tick.started_at ? new Date(tick.started_at).toLocaleString() : ""} + {tick.started_at ? formatTaskDateTime(tick.started_at) : ""}
{payload?.reason && ( @@ -1155,7 +1162,7 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) { cron_expr: initialData.cron_expr || "", delay_seconds: initialData.delay_seconds || 60, scheduled_at: initialData.next_run_at - ? new Date(initialData.next_run_at).toISOString().slice(0, 16) + ? formatDateTimeLocalInput(initialData.next_run_at) : "", max_runs: initialData.max_runs || "", tags: initialData.tags || "", @@ -1236,13 +1243,14 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) { // Handle scheduled_at: convert datetime-local to ISO timestamp if (form.schedule_type === "scheduled_at") { - const localDate = new Date(form.scheduled_at); - if (!form.scheduled_at || isNaN(localDate.getTime())) { + const localDate = parseTaskDateTime(form.scheduled_at); + const serialized = serializeDateTimeLocalInput(form.scheduled_at); + if (!form.scheduled_at || !serialized || !localDate || isNaN(localDate.getTime())) { setScheduledAtError("Please enter a valid date and time."); return; } setScheduledAtError(""); - data.next_run_at = localDate.toISOString(); + data.next_run_at = serialized; } onSubmit(data); @@ -1621,7 +1629,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) {
- ID: {task.id} · Created: {new Date(task.created_at).toLocaleString()} + ID: {task.id} · Created: {formatTaskDateTime(task.created_at)}
@@ -1654,7 +1662,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) { {task.cron_expr && } {task.delay_seconds && } - {task.next_run_at && } + {task.next_run_at && } {task.dag_id && }
@@ -1924,7 +1932,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) { {event.event_type} - {new Date(event.timestamp).toLocaleTimeString()} + {formatTaskTime(event.timestamp)}
{ + const parsed = parseTaskDateTime("2026-03-19T18:04:00"); + + assert.equal(parsed.getFullYear(), 2026); + assert.equal(parsed.getMonth(), 2); + assert.equal(parsed.getDate(), 19); + assert.equal(parsed.getHours(), 18); + assert.equal(parsed.getMinutes(), 4); +}); + +test("formatDateTimeLocalInput converts aware timestamps into local datetime-local values", () => { + assert.equal( + formatDateTimeLocalInput("2026-03-19T10:04:00+00:00"), + "2026-03-19T18:04", + ); +}); + +test("serializeDateTimeLocalInput preserves local wall time without forcing UTC", () => { + assert.equal( + serializeDateTimeLocalInput("2026-03-19T18:04"), + "2026-03-19T18:04:00", + ); +}); diff --git a/taskboard.py b/taskboard.py index b17d9b2..c611fce 100644 --- a/taskboard.py +++ b/taskboard.py @@ -90,6 +90,33 @@ def _get_env() -> dict: return env +def _parse_comparable_datetime(value: Optional[str]) -> Optional[datetime]: + """Parse ISO datetimes and collapse aware values into local naive datetimes. + + The app historically stored naive local timestamps, but the Electron UI can + submit offset-aware ISO strings for `scheduled_at`. Converting aware values + into the local timezone and stripping tzinfo keeps storage/comparisons + consistent with the rest of the backend while remaining backward compatible + with legacy rows. + """ + if not value: + return None + dt = datetime.fromisoformat(value) + if dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + return dt + + +def _normalize_datetime_for_storage(value: Optional[str]) -> Optional[str]: + if value is None: + return None + try: + dt = _parse_comparable_datetime(value) + except ValueError: + return value + return dt.isoformat() if dt else None + + # ──────────────────────────── Models ──────────────────────────── @@ -623,18 +650,25 @@ def get_all_heartbeats(self) -> list[dict]: return [self._deserialize_heartbeat(r) for r in rows] def get_due_heartbeats(self) -> list[dict]: - now = datetime.now().isoformat() with self.lock: rows = self.conn.execute( """ SELECT * FROM heartbeats WHERE enabled = 1 AND next_run_at IS NOT NULL - AND next_run_at <= ? - """, - (now,), + """ ).fetchall() - return [self._deserialize_heartbeat(r) for r in rows] + now = datetime.now() + due = [] + for row in rows: + heartbeat = self._deserialize_heartbeat(row) + try: + next_run_at = _parse_comparable_datetime(heartbeat.get("next_run_at")) + except ValueError: + continue + if next_run_at and next_run_at <= now: + due.append(heartbeat) + return due def delete_heartbeat(self, heartbeat_id: int): with self.transaction(): @@ -783,6 +817,8 @@ def update_task(self, task_id: int, **kwargs): if invalid: raise ValueError(f"Invalid task column(s): {invalid}") with self.lock: + if "next_run_at" in kwargs: + kwargs["next_run_at"] = _normalize_datetime_for_storage(kwargs["next_run_at"]) kwargs["updated_at"] = datetime.now().isoformat() sets = ", ".join(f"{k} = ?" for k in kwargs) vals = list(kwargs.values()) + [task_id] @@ -822,17 +858,24 @@ def get_all_tasks(self) -> list[dict]: return [self._deserialize_task(r) for r in rows] def get_due_tasks(self) -> list[dict]: - now = datetime.now().isoformat() with self.lock: rows = self.conn.execute( """ SELECT * FROM tasks WHERE status IN ('pending', 'scheduled') - AND (next_run_at IS NULL OR next_run_at <= ?) - """, - (now,), + """ ).fetchall() - return [self._deserialize_task(r) for r in rows] + now = datetime.now() + due = [] + for row in rows: + task = self._deserialize_task(row) + try: + next_run_at = _parse_comparable_datetime(task.get("next_run_at")) + except ValueError: + continue + if next_run_at is None or next_run_at <= now: + due.append(task) + return due def add_run(self, task_id: int) -> int: with self.lock: @@ -1195,15 +1238,18 @@ def _tick(self): self._schedule_delayed(task) elif task["schedule_type"] == "delayed" and task["status"] == "scheduled": nra = task.get("next_run_at") - if nra and datetime.fromisoformat(nra) <= datetime.now(): + run_at = _parse_comparable_datetime(nra) if nra else None + if run_at and run_at <= datetime.now(): self._spawn_task(task) elif task["schedule_type"] == "scheduled_at" and task["status"] == "scheduled": nra = task.get("next_run_at") - if nra and datetime.fromisoformat(nra) <= datetime.now(): + run_at = _parse_comparable_datetime(nra) if nra else None + if run_at and run_at <= datetime.now(): self._spawn_task(task) elif task["schedule_type"] == "cron" and task["status"] == "scheduled": nra = task.get("next_run_at") - if nra and datetime.fromisoformat(nra) <= datetime.now(): + run_at = _parse_comparable_datetime(nra) if nra else None + if run_at and run_at <= datetime.now(): self._spawn_task(task) due_heartbeats = self.db.get_due_heartbeats() for heartbeat in due_heartbeats: @@ -1427,7 +1473,7 @@ def _heartbeat_trigger_suppressed(self, heartbeat: dict, dedupe_key: str) -> boo triggered_at = existing.get("triggered_at") if triggered_at: try: - triggered_dt = datetime.fromisoformat(triggered_at) + triggered_dt = _parse_comparable_datetime(triggered_at) if cooldown > 0 and datetime.now() < triggered_dt + timedelta(seconds=cooldown): return True except ValueError: @@ -2211,6 +2257,7 @@ def submit_task(self, task: Task, depends_on: list = None) -> int: task.status = TaskStatus.SCHEDULED if not task.next_run_at: raise ValueError("scheduled_at requires next_run_at to be set") + task.next_run_at = _normalize_datetime_for_storage(task.next_run_at) elif task.schedule_type == ScheduleType.CRON: task.status = TaskStatus.SCHEDULED if task.cron_expr: diff --git a/tests/test_feishu_message_rendering.py b/tests/test_feishu_message_rendering.py index da1b228..54fb8c0 100644 --- a/tests/test_feishu_message_rendering.py +++ b/tests/test_feishu_message_rendering.py @@ -93,13 +93,71 @@ def test_build_completed_card_wraps_long_body_in_collapsible_panel(self, mock_fe body_text=long_text, ) - assert any( - element.get("tag") == "collapsible_panel" for element in card["body"]["elements"] + assert card["body"]["elements"][0]["tag"] == "collapsible_panel" + assert card["body"]["elements"][0]["elements"][0]["content"] == long_text + + def test_build_completed_card_expanded_panel_keeps_full_remainder(self, mock_feishu_channel): + task = { + "id": 100, + "title": "Long output task", + "prompt": "输出一份很长的结果", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + long_text = "A" * 500 + "B" * 7600 + + card = mock_feishu_channel._build_notification_card( + task_id=100, + task=task, + is_completed=True, + body_text=long_text, ) - assert any( - element.get("tag") == "markdown" and "AAAA" in element.get("content", "") - for element in card["body"]["elements"] + + panel = next( + element for element in card["body"]["elements"] if element.get("tag") == "collapsible_panel" ) + expanded_text = panel["elements"][0]["content"] + + assert expanded_text.startswith("A" * 20) + assert expanded_text.endswith("B" * 20) + assert "truncated" not in expanded_text + + def test_build_completed_card_long_body_uses_summary_plus_full_panel(self, mock_feishu_channel): + task = { + "id": 101, + "title": "Long output task", + "prompt": "输出一份很长的结果", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + long_text = "Summary line\n" + ("A" * 1600) + + card = mock_feishu_channel._build_notification_card( + task_id=101, + task=task, + is_completed=True, + body_text=long_text, + ) + + assert card["config"]["summary"]["content"] == "Summary line" + assert card["body"]["elements"] == [ + { + "tag": "collapsible_panel", + "expanded": False, + "header": { + "title": { + "tag": "plain_text", + "content": "展开查看完整结果", + } + }, + "elements": [ + { + "tag": "markdown", + "content": long_text, + } + ], + } + ] def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channel): task = { @@ -126,3 +184,32 @@ def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channe _, kwargs = mock_feishu_channel._send_message.call_args assert kwargs["fallback_content"] == "done" assert kwargs["card"]["schema"] == "2.0" + + def test_send_completed_notification_keeps_full_result_for_card(self, mock_feishu_channel): + task = { + "id": 6, + "title": "Long result", + "prompt": "发布长结果", + "result": None, + "error": None, + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + long_result = "A" * 12050 + mock_feishu_channel.db.get_task.return_value = task + mock_feishu_channel.db.get_setting.return_value = "oc_test_chat" + mock_feishu_channel._send_message = Mock(return_value="msg_456") + mock_feishu_channel._build_notification_card = Mock( + wraps=mock_feishu_channel._build_notification_card + ) + + msg = OutboundMessage( + type=OutboundMessageType.TASK_COMPLETED, + task_id=6, + payload={"result": long_result, "title": "Long result"}, + ) + + mock_feishu_channel.send(msg) + + _, kwargs = mock_feishu_channel._build_notification_card.call_args + assert kwargs["body_text"] == long_result diff --git a/tests/test_scheduler_timezones.py b/tests/test_scheduler_timezones.py new file mode 100644 index 0000000..d4c87ed --- /dev/null +++ b/tests/test_scheduler_timezones.py @@ -0,0 +1,96 @@ +from datetime import datetime, timedelta, timezone + +from taskboard import ScheduleType, Task, TaskDB, TaskScheduler + + +def make_db(tmp_path): + return TaskDB(str(tmp_path / "agentforge-test.db")) + + +def test_submit_task_normalizes_aware_scheduled_at_to_local_naive(tmp_path): + db = make_db(tmp_path) + scheduler = TaskScheduler(db) + future_utc = datetime.now(timezone.utc) + timedelta(hours=1) + + task_id = scheduler.submit_task( + Task( + title="Timezone test", + prompt="Run later", + schedule_type=ScheduleType.SCHEDULED_AT, + next_run_at=future_utc.isoformat(), + ) + ) + + task = db.get_task(task_id) + expected = future_utc.astimezone().replace(tzinfo=None).isoformat() + + assert task is not None + assert task["next_run_at"] == expected + + +def test_tick_accepts_legacy_aware_next_run_at_without_type_error(tmp_path, monkeypatch): + db = make_db(tmp_path) + scheduler = TaskScheduler(db) + triggered = [] + + now = datetime.now().isoformat() + cur = db.conn.execute( + """ + INSERT INTO tasks ( + title, prompt, working_dir, status, schedule_type, + cron_expr, delay_seconds, next_run_at, max_runs, + created_at, updated_at, tags, agent, prompt_images, image_paths + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "Legacy aware task", + "Run now", + ".", + "scheduled", + ScheduleType.SCHEDULED_AT.value, + None, + None, + (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), + None, + now, + now, + "", + "claude", + "[]", + "[]", + ), + ) + db.conn.commit() + task_id = cur.lastrowid + + monkeypatch.setattr(scheduler, "_spawn_task", lambda task: triggered.append(task["id"])) + + scheduler._tick() + + assert triggered == [task_id] + + +def test_heartbeat_dedupe_handles_aware_triggered_at_without_type_error(tmp_path): + db = make_db(tmp_path) + scheduler = TaskScheduler(db) + + db.conn.execute( + """ + INSERT INTO heartbeat_dedup (heartbeat_id, dedupe_key, task_id, triggered_at) + VALUES (?, ?, ?, ?) + """, + ( + 1, + "repo:abc123", + None, + (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat(), + ), + ) + db.conn.commit() + + suppressed = scheduler._heartbeat_trigger_suppressed( + {"id": 1, "cooldown_seconds": 300}, + "repo:abc123", + ) + + assert suppressed is True From 73692ea7493d3f3700a5b1f1bb4439a0f3a8a5be Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 20 Mar 2026 11:51:20 +0800 Subject: [PATCH 7/7] fix lint --- .claude/settings.local.json | 6 +++++- tests/test_feishu_message_rendering.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 82f4662..89a524f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,11 @@ "Bash(grep -v \"^$\\\\|^\\\\s*$\")", "WebFetch(domain:wttr.in)", "WebFetch(domain:richerculture.cn)", - "Skill(agent-browser)" + "Skill(agent-browser)", + "Bash(git fetch:*)", + "Bash(git merge:*)", + "Bash(uv run:*)", + "Bash(git:*)" ] } } diff --git a/tests/test_feishu_message_rendering.py b/tests/test_feishu_message_rendering.py index 54fb8c0..83f5ca1 100644 --- a/tests/test_feishu_message_rendering.py +++ b/tests/test_feishu_message_rendering.py @@ -114,7 +114,9 @@ def test_build_completed_card_expanded_panel_keeps_full_remainder(self, mock_fei ) panel = next( - element for element in card["body"]["elements"] if element.get("tag") == "collapsible_panel" + element + for element in card["body"]["elements"] + if element.get("tag") == "collapsible_panel" ) expanded_text = panel["elements"][0]["content"]