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
6 changes: 5 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*)"
]
}
}
39 changes: 23 additions & 16 deletions channels/feishu_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()[
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -415,21 +423,19 @@ 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",
"content": clean_body,
}
]

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,
Expand All @@ -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,
},
]

Expand All @@ -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("\\", "\\\\")

Expand Down
13 changes: 13 additions & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容
Expand Down
75 changes: 61 additions & 14 deletions taskboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
97 changes: 93 additions & 4 deletions tests/test_feishu_message_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Loading
Loading