From b8455c8691d3bd07a40280d3b1f7bb988bede77b Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 2 Jan 2026 04:36:05 +0400 Subject: [PATCH] refactor: simplify telegram markdown rendering (#20) --- docs/developing.md | 28 ++-- src/takopi/bridge.py | 109 +++++++--------- src/takopi/markdown.py | 84 ------------ src/takopi/render.py | 265 ++++++++++++++++++++++++-------------- tests/test_exec_bridge.py | 54 ++++---- tests/test_exec_render.py | 121 ++++++++++++++--- tests/test_rendering.py | 2 +- 7 files changed, 360 insertions(+), 303 deletions(-) delete mode 100644 src/takopi/markdown.py diff --git a/docs/developing.md b/docs/developing.md index c7ba50f..f856acb 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -60,13 +60,16 @@ The orchestrator module containing: | `run()` / `main()` | Typer CLI entry points | | `_parse_bridge_config()` | Reads config + builds `BridgeConfig` | -### `markdown.py` - Telegram markdown helpers +### `render.py` - Takopi event + Markdown helpers -| Function | Purpose | -|----------|---------| +| Function/Class | Purpose | +|----------------|---------| | `render_markdown()` | Markdown → Telegram text + entities | -| `prepare_telegram()` | Render + truncate for Telegram limits | -| `truncate_for_telegram()` | Smart truncation preserving resume lines | +| `trim_body()` | Trim body to 3500 chars (header/footer preserved) | +| `prepare_telegram()` | Trim + render Markdown parts for Telegram | +| `ExecProgressRenderer` | Stateful renderer tracking recent actions for progress display | +| `render_event_cli()` | Format a takopi event for CLI logs | +| `format_elapsed()` | Formats seconds as `Xh Ym`, `Xm Ys`, or `Xs` | ### `telegram.py` - Telegram API wrapper @@ -89,21 +92,6 @@ The orchestrator module containing: - Stderr is drained into a bounded tail (debug logging only) - Event callbacks must not raise; callback errors abort the run -### `render.py` - Takopi event rendering - -Transforms takopi events into human-readable text: - -| Function/Class | Purpose | -|----------------|---------| -| `ExecProgressRenderer` | Stateful renderer tracking recent actions for progress display | -| `render_event_cli()` | Format a takopi event for CLI logs | -| `format_elapsed()` | Formats seconds as `Xh Ym`, `Xm Ys`, or `Xs` | - -**Supported event types:** -- `started` -- `action` -- `completed` - ### `model.py` / `runner.py` - Core domain types | File | Purpose | diff --git a/src/takopi/bridge.py b/src/takopi/bridge.py index fc4d541..ac6cdc1 100644 --- a/src/takopi/bridge.py +++ b/src/takopi/bridge.py @@ -11,9 +11,14 @@ from typing import Any import anyio -from .markdown import TELEGRAM_MARKDOWN_LIMIT, prepare_telegram from .model import CompletedEvent, EngineId, ResumeToken, StartedEvent, TakopiEvent -from .render import ExecProgressRenderer, render_event_cli +from .render import ( + ExecProgressRenderer, + MarkdownParts, + assemble_markdown_parts, + prepare_telegram, + render_event_cli, +) from .router import AutoRouter, RunnerUnavailableError from .runner import Runner from .telegram import BotClient @@ -152,18 +157,14 @@ async def _send_or_edit_markdown( bot: BotClient, *, chat_id: int, - text: str, + parts: MarkdownParts, edit_message_id: int | None = None, reply_to_message_id: int | None = None, disable_notification: bool = False, - limit: int = TELEGRAM_MARKDOWN_LIMIT, - is_resume_line: Callable[[str], bool] | None = None, - prepared: tuple[str, list[dict[str, Any]] | None] | None = None, + prepared: tuple[str, list[dict[str, Any]]] | None = None, ) -> tuple[dict[str, Any] | None, bool]: if prepared is None: - rendered, entities = prepare_telegram( - text, limit=limit, is_resume_line=is_resume_line - ) + rendered, entities = prepare_telegram(parts) else: rendered, entities = prepared if edit_message_id is not None: @@ -200,10 +201,8 @@ class ProgressEdits: progress_edit_every: float, clock: Callable[[], float], sleep: Callable[[float], Awaitable[None]], - limit: int, last_edit_at: float, last_rendered: str | None, - is_resume_line: Callable[[str], bool], ) -> None: self.bot = bot self.chat_id = chat_id @@ -213,10 +212,8 @@ class ProgressEdits: self.progress_edit_every = progress_edit_every self.clock = clock self.sleep = sleep - self.limit = limit self.last_edit_at = last_edit_at self.last_rendered = last_rendered - self.is_resume_line = is_resume_line self.event_seq = 0 self.rendered_seq = 0 self.signal_send, self.signal_recv = anyio.create_memory_object_stream(1) @@ -240,10 +237,9 @@ class ProgressEdits: seq_at_render = self.event_seq now = self.clock() - md = self.renderer.render_progress(now - self.started_at) - rendered, entities = prepare_telegram( - md, limit=self.limit, is_resume_line=self.is_resume_line - ) + parts = self.renderer.render_progress_parts(now - self.started_at) + md = assemble_markdown_parts(parts) + rendered, entities = prepare_telegram(parts) if rendered != self.last_rendered: logger.debug( "[progress] edit message_id=%s md=%s", self.progress_id, md @@ -297,8 +293,7 @@ async def _send_startup(cfg: BridgeConfig) -> None: sent, _ = await _send_or_edit_markdown( cfg.bot, chat_id=cfg.chat_id, - text=cfg.startup_msg, - limit=TELEGRAM_MARKDOWN_LIMIT, + parts=MarkdownParts(header=cfg.startup_msg), ) if sent is not None: logger.info("[startup] sent startup message to chat_id=%s", cfg.chat_id) @@ -336,18 +331,15 @@ async def send_initial_progress( user_msg_id: int, label: str, renderer: ExecProgressRenderer, - is_resume_line: Callable[[str], bool], clock: Callable[[], float], - limit: int, ) -> ProgressMessageState: progress_id: int | None = None last_edit_at = 0.0 last_rendered: str | None = None - initial_md = renderer.render_progress(0.0, label=label) - initial_rendered, initial_entities = prepare_telegram( - initial_md, limit=limit, is_resume_line=is_resume_line - ) + initial_parts = renderer.render_progress_parts(0.0, label=label) + initial_md = assemble_markdown_parts(initial_parts) + initial_rendered, initial_entities = prepare_telegram(initial_parts) logger.debug( "[progress] send reply_to=%s md=%s rendered=%s entities=%s", user_msg_id, @@ -438,22 +430,19 @@ async def send_result_message( chat_id: int, user_msg_id: int, progress_id: int | None, - markdown: str, + parts: MarkdownParts, disable_notification: bool, edit_message_id: int | None, - is_resume_line: Callable[[str], bool], - prepared: tuple[str, list[dict[str, Any]] | None] | None = None, + prepared: tuple[str, list[dict[str, Any]]] | None = None, delete_tag: str = "final", ) -> None: final_msg, edited = await _send_or_edit_markdown( cfg.bot, chat_id=chat_id, - text=markdown, + parts=parts, edit_message_id=edit_message_id, reply_to_message_id=user_msg_id, disable_notification=disable_notification, - limit=TELEGRAM_MARKDOWN_LIMIT, - is_resume_line=is_resume_line, prepared=prepared, ) if final_msg is None: @@ -492,18 +481,16 @@ async def handle_message( runner_text = _strip_resume_lines(text, is_resume_line=resume_strip) progress_renderer = ExecProgressRenderer( - max_actions=5, resume_formatter=runner.format_resume + max_actions=5, resume_formatter=runner.format_resume, engine=runner.engine ) progress_state = await send_initial_progress( cfg, chat_id=chat_id, user_msg_id=user_msg_id, - label=f"working ({runner.engine})", + label="starting", renderer=progress_renderer, - is_resume_line=is_resume_line, clock=clock, - limit=TELEGRAM_MARKDOWN_LIMIT, ) progress_id = progress_state.message_id @@ -516,10 +503,8 @@ async def handle_message( progress_edit_every=progress_edit_every, clock=clock, sleep=sleep, - limit=TELEGRAM_MARKDOWN_LIMIT, last_edit_at=progress_state.last_edit_at, last_rendered=progress_state.last_rendered, - is_resume_line=is_resume_line, ) running_task: RunningTask | None = None @@ -575,17 +560,21 @@ async def handle_message( if error is not None: sync_resume_token(progress_renderer, outcome.resume) err_body = _format_error(error) - final_md = progress_renderer.render_final(elapsed, err_body, status="error") - logger.debug("[error] markdown: %s", final_md) + final_parts = progress_renderer.render_final_parts( + elapsed, err_body, status="error" + ) + logger.debug( + "[error] markdown: %s", + assemble_markdown_parts(final_parts), + ) await send_result_message( cfg, chat_id=chat_id, user_msg_id=user_msg_id, progress_id=progress_id, - markdown=final_md, + parts=final_parts, disable_notification=True, edit_message_id=progress_id, - is_resume_line=is_resume_line, delete_tag="error", ) return @@ -597,16 +586,17 @@ async def handle_message( resume.value if resume else None, elapsed, ) - final_md = progress_renderer.render_progress(elapsed, label="`cancelled`") + final_parts = progress_renderer.render_progress_parts( + elapsed, label="`cancelled`" + ) await send_result_message( cfg, chat_id=chat_id, user_msg_id=user_msg_id, progress_id=progress_id, - markdown=final_md, + parts=final_parts, disable_notification=True, edit_message_id=progress_id, - is_resume_line=is_resume_line, delete_tag="cancel", ) return @@ -629,13 +619,16 @@ async def handle_message( "error" if run_ok is False else ("done" if final_answer.strip() else "error") ) sync_resume_token(progress_renderer, completed.resume or outcome.resume) - final_md = progress_renderer.render_final(elapsed, final_answer, status=status) - logger.debug("[final] markdown: %s", final_md) - - final_rendered, final_entities = prepare_telegram( - final_md, limit=TELEGRAM_MARKDOWN_LIMIT, is_resume_line=is_resume_line + final_parts = progress_renderer.render_final_parts( + elapsed, final_answer, status=status ) - can_edit_final = progress_id is not None and final_entities is not None + logger.debug( + "[final] markdown: %s", + assemble_markdown_parts(final_parts), + ) + + final_rendered, final_entities = prepare_telegram(final_parts) + can_edit_final = progress_id is not None edit_message_id = None if cfg.final_notify or not can_edit_final else progress_id if edit_message_id is None: @@ -658,10 +651,9 @@ async def handle_message( chat_id=chat_id, user_msg_id=user_msg_id, progress_id=progress_id, - markdown=final_md, + parts=final_parts, disable_notification=False, edit_message_id=edit_message_id, - is_resume_line=is_resume_line, prepared=(final_rendered, final_entities), delete_tag="final", ) @@ -784,19 +776,19 @@ async def _send_runner_unavailable( reason: str, ) -> None: progress_renderer = ExecProgressRenderer( - max_actions=0, resume_formatter=runner.format_resume + max_actions=0, resume_formatter=runner.format_resume, engine=runner.engine ) if resume_token is not None: progress_renderer.resume_token = resume_token - final_md = progress_renderer.render_final(0.0, f"Error:\n{reason}", status="error") + final_parts = progress_renderer.render_final_parts( + 0.0, f"error:\n{reason}", status="error" + ) await _send_or_edit_markdown( cfg.bot, chat_id=chat_id, - text=final_md, + parts=final_parts, reply_to_message_id=user_msg_id, disable_notification=False, - limit=TELEGRAM_MARKDOWN_LIMIT, - is_resume_line=runner.is_resume_line, ) @@ -859,10 +851,9 @@ async def run_main_loop( await _send_or_edit_markdown( cfg.bot, chat_id=chat_id, - text=f"Error:\n{exc}", + parts=MarkdownParts(header=f"error:\n{exc}"), reply_to_message_id=user_msg_id, disable_notification=False, - limit=TELEGRAM_MARKDOWN_LIMIT, ) return if not entry.available: diff --git a/src/takopi/markdown.py b/src/takopi/markdown.py deleted file mode 100644 index 1a9d01d..0000000 --- a/src/takopi/markdown.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Markdown rendering and truncation helpers for Telegram constraints.""" - -from __future__ import annotations - -import re -from collections.abc import Callable -from typing import Any - -from markdown_it import MarkdownIt -from sulguk import transform_html - -TELEGRAM_MARKDOWN_LIMIT = 3500 - -_md = MarkdownIt("commonmark", {"html": False}) - - -def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]: - html = _md.render(md or "") - rendered = transform_html(html) - - text = re.sub(r"(?m)^(\s*)•", r"\1-", rendered.text) - - entities = [dict(e) for e in rendered.entities] - return text, entities - - -def truncate_for_telegram( - text: str, limit: int, *, is_resume_line: Callable[[str], bool] -) -> str: - """ - Truncate text to fit Telegram limits while preserving the trailing resume command - line (if present), otherwise preserving the last non-empty line. - """ - if len(text) <= limit: - return text - - lines = text.splitlines() - - tail_lines: list[str] | None = None - is_resume_tail = False - for i in range(len(lines) - 1, -1, -1): - line = lines[i] - if is_resume_line(line): - tail_lines = lines[i:] - is_resume_tail = True - break - - if tail_lines is None: - for i in range(len(lines) - 1, -1, -1): - if lines[i].strip(): - tail_lines = [lines[i]] - break - - tail = "\n".join(tail_lines or []).strip("\n") - sep = "\n…\n" - - max_tail = limit if is_resume_tail else (limit // 4) - tail = tail[-max_tail:] if max_tail > 0 else "" - - head_budget = limit - len(sep) - len(tail) - if head_budget <= 0: - return tail[-limit:] if tail else text[:limit] - - head = text[:head_budget].rstrip() - return (head + sep + tail)[:limit] - - -def prepare_telegram( - md: str, - *, - limit: int, - is_resume_line: Callable[[str], bool] | None = None, -) -> tuple[str, list[dict[str, Any]] | None]: - rendered, entities = render_markdown(md) - if len(rendered) > limit: - if is_resume_line is None: - - def _never_resume_line(_line: str) -> bool: - return False - - is_resume_line = _never_resume_line - rendered = truncate_for_telegram(rendered, limit, is_resume_line=is_resume_line) - return rendered, None - return rendered, entities diff --git a/src/takopi/render.py b/src/takopi/render.py index 4c89ddb..280f040 100644 --- a/src/takopi/render.py +++ b/src/takopi/render.py @@ -2,18 +2,21 @@ from __future__ import annotations +import re import textwrap from collections import deque from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +from typing import Any + +from markdown_it import MarkdownIt +from sulguk import transform_html from .model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent from .utils.paths import relativize_path -STATUS_RUNNING = "▸" -STATUS_UPDATE = "↻" -STATUS_DONE = "✓" -STATUS_FAIL = "✗" +STATUS = {"running": "▸", "update": "↻", "done": "✓", "fail": "✗"} HEADER_SEP = " · " HARD_BREAK = " \n" @@ -21,6 +24,47 @@ MAX_PROGRESS_CMD_LEN = 300 MAX_FILE_CHANGES_INLINE = 3 +@dataclass(frozen=True) +class MarkdownParts: + header: str + body: str | None = None + footer: str | None = None + + +def assemble_markdown_parts(parts: MarkdownParts) -> str: + return "\n\n".join( + chunk for chunk in (parts.header, parts.body, parts.footer) if chunk + ) + + +def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]: + md_renderer = MarkdownIt("commonmark", {"html": False}) + html = md_renderer.render(md or "") + rendered = transform_html(html) + + text = re.sub(r"(?m)^(\s*)•", r"\1-", rendered.text) + + entities = [dict(e) for e in rendered.entities] + return text, entities + + +def trim_body(body: str | None) -> str | None: + if not body: + return None + if len(body) > 3500: + body = body[: 3500 - 1] + "…" + return body if body.strip() else None + + +def prepare_telegram(parts: MarkdownParts) -> tuple[str, list[dict[str, Any]]]: + trimmed = MarkdownParts( + header=parts.header or "", + body=trim_body(parts.body), + footer=parts.footer, + ) + return render_markdown(assemble_markdown_parts(trimmed)) + + def format_changed_file_path(path: str, *, base_dir: Path | None = None) -> str: return f"`{relativize_path(path, base_dir=base_dir)}`" @@ -36,9 +80,12 @@ def format_elapsed(elapsed_s: float) -> str: return f"{seconds}s" -def format_header(elapsed_s: float, item: int | None, label: str) -> str: +def format_header( + elapsed_s: float, item: int | None, *, label: str, engine: str +) -> str: elapsed = format_elapsed(elapsed_s) - parts = [label, elapsed] + parts = [label, engine] + parts.append(elapsed) if item is not None: parts.append(f"step {item}") return HEADER_SEP.join(parts) @@ -47,24 +94,26 @@ def format_header(elapsed_s: float, item: int | None, label: str) -> str: def shorten(text: str, width: int | None) -> str: if width is None: return text + if width <= 0: + return "" + if len(text) <= width: + return text return textwrap.shorten(text, width=width, placeholder="…") -def action_status_symbol( - action: Action, *, completed: bool, ok: bool | None = None -) -> str: +def action_status(action: Action, *, completed: bool, ok: bool | None = None) -> str: if not completed: - return STATUS_RUNNING + return STATUS["running"] if ok is not None: - return STATUS_DONE if ok else STATUS_FAIL + return STATUS["done"] if ok else STATUS["fail"] detail = action.detail or {} exit_code = detail.get("exit_code") if isinstance(exit_code, int) and exit_code != 0: - return STATUS_FAIL - return STATUS_DONE + return STATUS["fail"] + return STATUS["done"] -def action_exit_suffix(action: Action) -> str: +def action_suffix(action: Action) -> str: detail = action.detail or {} exit_code = detail.get("exit_code") if isinstance(exit_code, int) and exit_code != 0: @@ -118,17 +167,25 @@ def format_action_title(action: Action, *, command_width: int | None) -> str: return shorten(title, command_width) -def phase_status_and_suffix(event: ActionEvent) -> tuple[str, str]: - action = event.action - match event.phase: - case "completed": - status = action_status_symbol(action, completed=True, ok=event.ok) - suffix = action_exit_suffix(action) - return status, suffix - case "updated": - return STATUS_UPDATE, "" - case _: - return STATUS_RUNNING, "" +def format_action_line( + action: Action, + phase: str, + ok: bool | None, + *, + command_width: int | None, +) -> str: + if phase != "completed": + status = STATUS["update"] if phase == "updated" else STATUS["running"] + return f"{status} {format_action_title(action, command_width=command_width)}" + status = action_status(action, completed=True, ok=ok) + suffix = action_suffix(action) + return ( + f"{status} {format_action_title(action, command_width=command_width)}{suffix}" + ) + + +def is_command_log_line(line: str) -> bool: + return any(line.startswith(f"{symbol} `") for symbol in STATUS.values()) def render_event_cli(event: TakopiEvent) -> list[str]: @@ -139,32 +196,44 @@ def render_event_cli(event: TakopiEvent) -> list[str]: action = action_event.action if action.kind == "turn": return [] - status, suffix = phase_status_and_suffix(action_event) - title = format_action_title(action, command_width=MAX_PROGRESS_CMD_LEN) - return [f"{status} {title}{suffix}"] + return [ + format_action_line( + action_event.action, + action_event.phase, + action_event.ok, + command_width=MAX_PROGRESS_CMD_LEN, + ) + ] case _: return [] +@dataclass +class RecentLine: + action_id: str + text: str + completed: bool = False + + class ExecProgressRenderer: def __init__( self, + engine: str, max_actions: int = 5, command_width: int | None = MAX_PROGRESS_CMD_LEN, resume_formatter: Callable[[ResumeToken], str] | None = None, show_title: bool = False, ) -> None: - self.max_actions = max_actions + self.max_actions = max(0, int(max_actions)) self.command_width = command_width - self.recent_actions: deque[str] = deque(maxlen=max_actions) - self._recent_action_ids: deque[str] = deque(maxlen=max_actions) - self._recent_action_completed: deque[bool] = deque(maxlen=max_actions) + self.lines: deque[RecentLine] = deque(maxlen=self.max_actions) self.action_count = 0 - self._started_counts: dict[str, int] = {} + self.seen_action_ids: set[str] = set() self.resume_token: ResumeToken | None = None self.session_title: str | None = None self._resume_formatter = resume_formatter self.show_title = show_title + self.engine = engine def note_event(self, event: TakopiEvent) -> bool: match event: @@ -179,85 +248,89 @@ class ExecProgressRenderer: if not action_id: return False completed = phase == "completed" - if completed: - is_update = False - else: - started_count = self._started_counts.get(action_id, 0) - is_update = phase == "updated" or started_count > 0 - if started_count == 0: - self.action_count += 1 - self._started_counts[action_id] = 1 - elif phase == "started": - self._started_counts[action_id] = started_count + 1 - else: - self._started_counts[action_id] = started_count + has_open = self.has_open_line(action_id) + is_update = phase == "updated" or (phase == "started" and has_open) + phase_for_line = "updated" if is_update and not completed else phase + line = format_action_line( + action, phase_for_line, ok, command_width=self.command_width + ) + + if action_id not in self.seen_action_ids: + self.seen_action_ids.add(action_id) + self.action_count += 1 + + self.upsert_line(action_id, line=line, completed=completed) + return True case _: return False - if completed: - count = self._started_counts.get(action_id, 0) - if count <= 0: - self.action_count += 1 - elif count == 1: - self._started_counts.pop(action_id, None) - else: - self._started_counts[action_id] = count - 1 - - status = ( - STATUS_UPDATE - if (is_update and not completed) - else action_status_symbol(action, completed=completed, ok=ok) + def has_open_line(self, action_id: str) -> bool: + return any( + line.action_id == action_id and not line.completed for line in self.lines ) - title = format_action_title(action, command_width=self.command_width) - suffix = action_exit_suffix(action) if completed else "" - line = f"{status} {title}{suffix}" - self._append_action(action_id, completed=completed, line=line) - return True - - def _append_action(self, action_id: str, *, completed: bool, line: str) -> None: - for i in range(len(self._recent_action_ids) - 1, -1, -1): - if ( - self._recent_action_ids[i] == action_id - and not self._recent_action_completed[i] - ): - self.recent_actions[i] = line - if completed: - self._recent_action_completed[i] = True + def upsert_line(self, action_id: str, *, line: str, completed: bool) -> None: + for i in range(len(self.lines) - 1, -1, -1): + existing = self.lines[i] + if existing.action_id == action_id and not existing.completed: + self.lines[i] = RecentLine( + action_id=action_id, + text=line, + completed=existing.completed or completed, + ) return + self.lines.append( + RecentLine(action_id=action_id, text=line, completed=completed) + ) - if len(self.recent_actions) >= self.max_actions: - self.recent_actions.popleft() - self._recent_action_ids.popleft() - self._recent_action_completed.popleft() - - self.recent_actions.append(line) - self._recent_action_ids.append(action_id) - self._recent_action_completed.append(completed) - - def render_progress(self, elapsed_s: float, label: str = "working") -> str: + def render_progress_parts( + self, elapsed_s: float, label: str = "working" + ) -> MarkdownParts: step = self.action_count or None - header = format_header(elapsed_s, step, label=self._label_with_title(label)) - message = self._assemble(header, list(self.recent_actions)) - return self._append_resume(message) + header = format_header( + elapsed_s, + step, + label=self.label_with_title(label), + engine=self.engine, + ) + body = self.assemble_body([line.text for line in self.lines]) + return MarkdownParts(header=header, body=body, footer=self.render_footer()) - def render_final(self, elapsed_s: float, answer: str, status: str = "done") -> str: + def render_final_parts( + self, elapsed_s: float, answer: str, status: str = "done" + ) -> MarkdownParts: step = self.action_count or None - header = format_header(elapsed_s, step, label=self._label_with_title(status)) + header = format_header( + elapsed_s, + step, + label=self.label_with_title(status), + engine=self.engine, + ) + lines = [line.text for line in self.lines] + if status == "done": + lines = [line for line in lines if not is_command_log_line(line)] + body = self.assemble_body(lines) answer = (answer or "").strip() - message = header + ("\n\n" + answer if answer else "") - return self._append_resume(message) + if answer: + body = answer if not body else body + "\n\n" + answer + return MarkdownParts(header=header, body=body, footer=self.render_footer()) - def _label_with_title(self, label: str) -> str: + def label_with_title(self, label: str) -> str: if self.show_title and self.session_title: return f"{label} ({self.session_title})" return label - def _append_resume(self, message: str) -> str: + def render_footer(self) -> str | None: if not self.resume_token or self._resume_formatter is None: - return message - return message + "\n\n" + self._resume_formatter(self.resume_token) + return None + return self._resume_formatter(self.resume_token) + + @property + def recent_actions(self) -> list[str]: + return [line.text for line in self.lines] @staticmethod - def _assemble(header: str, lines: list[str]) -> str: - return header if not lines else header + "\n\n" + HARD_BREAK.join(lines) + def assemble_body(lines: list[str]) -> str | None: + if not lines: + return None + return HARD_BREAK.join(lines) diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index 381e7af..384ce9d 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -4,8 +4,8 @@ import anyio import pytest from takopi.bridge import _build_bot_commands, _strip_engine_command -from takopi.markdown import prepare_telegram, truncate_for_telegram from takopi.model import EngineId, ResumeToken, TakopiEvent +from takopi.render import MarkdownParts, prepare_telegram from takopi.router import AutoRouter, RunnerEntry from takopi.runners.codex import CodexRunner from takopi.runners.mock import Advance, Emit, Raise, Return, ScriptRunner, Sleep, Wait @@ -101,25 +101,33 @@ def test_codex_extract_resume_accepts_uuid7() -> None: assert runner.extract_resume(text) == ResumeToken(engine=CODEX_ENGINE, value=token) -def test_truncate_for_telegram_preserves_resume_line() -> None: - uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2" - md = ("x" * 10_000) + f"\n`codex resume {uuid}`" +def test_prepare_telegram_trims_body_preserves_footer() -> None: + body_limit = 3500 + parts = MarkdownParts( + header="header", + body="x" * (body_limit + 100), + footer="footer", + ) - runner = CodexRunner(codex_cmd="codex", extra_args=[]) - out = truncate_for_telegram(md, 400, is_resume_line=runner.is_resume_line) + rendered, _ = prepare_telegram(parts) - assert len(out) <= 400 - assert f"codex resume {uuid}" in out - assert out.rstrip().endswith(f"`codex resume {uuid}`") + chunks = [chunk for chunk in rendered.split("\n\n") if chunk] + assert chunks[0] == "header" + assert chunks[-1].rstrip() == "footer" + assert len(chunks[1]) == body_limit + assert chunks[1].endswith("…") -def test_truncate_for_telegram_keeps_last_non_empty_line() -> None: - md = "intro\n\n" + ("x" * 500) + "\nlast line" +def test_prepare_telegram_preserves_entities_on_truncate() -> None: + body_limit = 3500 + parts = MarkdownParts( + header="h", + body="**bold** " + ("x" * (body_limit + 100)), + ) - out = truncate_for_telegram(md, 120, is_resume_line=lambda _line: False) + _, entities = prepare_telegram(parts) - assert len(out) <= 120 - assert out.rstrip().endswith("last line") + assert any(e.get("type") == "bold" for e in entities) def test_strip_engine_command_inline() -> None: @@ -171,15 +179,6 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None: assert any(cmd["command"] == "codex" for cmd in commands) -def test_prepare_telegram_drops_entities_on_truncate() -> None: - md = ("**bold** " * 200).strip() - - rendered, entities = prepare_telegram(md, limit=40) - - assert len(rendered) <= 40 - assert entities is None - - class _FakeBot: def __init__(self) -> None: self._next_id = 1 @@ -364,7 +363,7 @@ async def test_handle_message_strips_resume_line_from_prompt() -> None: @pytest.mark.anyio -async def test_new_final_message_forces_notification_when_too_long_to_edit() -> None: +async def test_long_final_message_edits_progress_message() -> None: from takopi.bridge import BridgeConfig, handle_message bot = _FakeBot() @@ -386,9 +385,9 @@ async def test_new_final_message_forces_notification_when_too_long_to_edit() -> resume_token=None, ) - assert len(bot.send_calls) == 2 + assert len(bot.send_calls) == 1 assert bot.send_calls[0]["disable_notification"] is True - assert bot.send_calls[1]["disable_notification"] is False + assert len(bot.edit_calls) == 1 @pytest.mark.anyio @@ -553,7 +552,8 @@ async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None: ) assert bot.send_calls[0]["reply_to_message_id"] == 42 - assert "working" in bot.send_calls[0]["text"] + assert "starting" in bot.send_calls[0]["text"] + assert "codex" in bot.send_calls[0]["text"] assert len(bot.edit_calls) >= 1 assert session_id in bot.send_calls[-1]["text"] assert "codex resume" in bot.send_calls[-1]["text"].lower() diff --git a/tests/test_exec_render.py b/tests/test_exec_render.py index f930485..68d56bc 100644 --- a/tests/test_exec_render.py +++ b/tests/test_exec_render.py @@ -2,9 +2,18 @@ from typing import cast from types import SimpleNamespace from pathlib import Path -from takopi.markdown import render_markdown -from takopi.model import TakopiEvent -from takopi.render import ExecProgressRenderer, render_event_cli +from takopi.model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent +from takopi.render import ( + ExecProgressRenderer, + STATUS, + action_status, + assemble_markdown_parts, + format_elapsed, + format_file_change_title, + render_event_cli, + render_markdown, + shorten, +) from tests.factories import ( action_completed, action_started, @@ -109,17 +118,21 @@ def test_file_change_renders_relative_paths_inside_cwd() -> None: def test_progress_renderer_renders_progress_and_final() -> None: - r = ExecProgressRenderer(max_actions=5, resume_formatter=_format_resume) + r = ExecProgressRenderer( + max_actions=5, resume_formatter=_format_resume, engine="codex" + ) for evt in SAMPLE_EVENTS: r.note_event(evt) - progress = r.render_progress(3.0) - assert progress.startswith("working · 3s · step 2") + progress_parts = r.render_progress_parts(3.0) + progress = assemble_markdown_parts(progress_parts) + assert progress.startswith("working · codex · 3s · step 2") assert "✓ `bash -lc ls`" in progress assert "`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`" in progress - final = r.render_final(3.0, "answer", status="done") - assert final.startswith("done · 3s · step 2") + final_parts = r.render_final_parts(3.0, "answer", status="done") + final = assemble_markdown_parts(final_parts) + assert final.startswith("done · codex · 3s · step 2") assert "answer" in final assert final.rstrip().endswith( "`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`" @@ -127,7 +140,7 @@ def test_progress_renderer_renders_progress_and_final() -> None: def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None: - r = ExecProgressRenderer(max_actions=3, command_width=20) + r = ExecProgressRenderer(max_actions=3, command_width=20, engine="codex") events = [ action_completed( f"item_{i}", @@ -150,7 +163,7 @@ def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None: def test_progress_renderer_renders_commands_in_markdown() -> None: - r = ExecProgressRenderer(max_actions=5, command_width=None) + r = ExecProgressRenderer(max_actions=5, command_width=None, engine="codex") for i in (30, 31, 32): r.note_event( action_completed( @@ -162,7 +175,7 @@ def test_progress_renderer_renders_commands_in_markdown() -> None: ) ) - md = r.render_progress(0.0) + md = assemble_markdown_parts(r.render_progress_parts(0.0)) text, _ = render_markdown(md) assert "✓ echo 30" in text assert "✓ echo 31" in text @@ -170,7 +183,7 @@ def test_progress_renderer_renders_commands_in_markdown() -> None: def test_progress_renderer_handles_duplicate_action_ids() -> None: - r = ExecProgressRenderer(max_actions=5) + r = ExecProgressRenderer(max_actions=5, engine="codex") events = [ action_started("dup", "command", "echo first"), action_completed( @@ -201,7 +214,7 @@ def test_progress_renderer_handles_duplicate_action_ids() -> None: def test_progress_renderer_collapses_action_updates() -> None: - r = ExecProgressRenderer(max_actions=5) + r = ExecProgressRenderer(max_actions=5, engine="codex") events = [ action_started("a-1", "command", "echo one"), action_started("a-1", "command", "echo two"), @@ -234,11 +247,87 @@ def test_progress_renderer_deterministic_output() -> None: detail={"exit_code": 0}, ), ] - r1 = ExecProgressRenderer(max_actions=5) - r2 = ExecProgressRenderer(max_actions=5) + r1 = ExecProgressRenderer(max_actions=5, engine="codex") + r2 = ExecProgressRenderer(max_actions=5, engine="codex") for evt in events: r1.note_event(evt) r2.note_event(evt) - assert r1.render_progress(1.0) == r2.render_progress(1.0) + assert assemble_markdown_parts( + r1.render_progress_parts(1.0) + ) == assemble_markdown_parts(r2.render_progress_parts(1.0)) + + +def test_format_elapsed_branches() -> None: + assert format_elapsed(3661) == "1h 01m" + assert format_elapsed(61) == "1m 01s" + assert format_elapsed(1.4) == "1s" + + +def test_shorten_and_action_status_branches() -> None: + assert shorten("hello", None) == "hello" + assert shorten("hello", 0) == "" + shortened = shorten("hello world", 6) + assert shortened.endswith("…") + assert len(shortened) <= 6 + + action_ok = Action(id="ok", kind="command", title="x", detail={"exit_code": 0}) + action_fail = Action(id="fail", kind="command", title="x", detail={"exit_code": 2}) + + assert action_status(action_ok, completed=False, ok=None) == STATUS["running"] + assert action_status(action_ok, completed=True, ok=None) == STATUS["done"] + assert action_status(action_fail, completed=True, ok=None) == STATUS["fail"] + + +def test_format_file_change_title_handles_overflow_and_invalid() -> None: + action = Action( + id="f", + kind="file_change", + title="files", + detail={ + "changes": [ + "bad", + {"path": ""}, + {"path": "a", "kind": "add"}, + {"path": "b"}, + {"path": "c"}, + {"path": "d"}, + ] + }, + ) + title = format_file_change_title(action, command_width=200) + assert title.startswith("files: ") + assert "…(" in title + + fallback = format_file_change_title( + Action(id="empty", kind="file_change", title="all files"), command_width=50 + ) + assert fallback == "files: all files" + + +def test_render_event_cli_ignores_turn_actions() -> None: + event = ActionEvent( + engine="codex", + action=Action(id="turn", kind="turn", title="turn"), + phase="started", + ok=None, + ) + assert render_event_cli(event) == [] + + +def test_progress_renderer_ignores_missing_action_id_and_titles() -> None: + renderer = ExecProgressRenderer(engine="codex", show_title=True) + resume = ResumeToken(engine="codex", value="abc") + renderer.note_event(StartedEvent(engine="codex", resume=resume, title="Session")) + + event = ActionEvent( + engine="codex", + action=Action(id="", kind="command", title="echo"), + phase="started", + ok=None, + ) + assert renderer.note_event(event) is False + + header = assemble_markdown_parts(renderer.render_progress_parts(0.0)) + assert header.startswith("working (Session) · codex · 0s") diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 5eae329..741309d 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -1,4 +1,4 @@ -from takopi.markdown import render_markdown +from takopi.render import render_markdown def test_render_markdown_basic_entities() -> None: