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/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..46b6b82 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.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..83f5ca1 100644 --- a/tests/test_feishu_message_rendering.py +++ b/tests/test_feishu_message_rendering.py @@ -93,14 +93,74 @@ 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", "") + + 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 = { "id": 5, @@ -126,3 +186,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