refactor!: split telegram bridge into transport/presenter (#55)

This commit is contained in:
banteg
2026-01-06 02:14:36 +04:00
committed by GitHub
parent a8eb1290cc
commit 1178b738df
26 changed files with 2338 additions and 1833 deletions
+126 -670
View File
@@ -3,25 +3,18 @@ import uuid
import anyio
import pytest
from takopi.bridge import _build_bot_commands, _strip_engine_command
from takopi.runner_bridge import ExecBridgeConfig, IncomingMessage, handle_message
from takopi.markdown import MarkdownParts, MarkdownPresenter
from takopi.model import EngineId, ResumeToken, TakopiEvent
from takopi.render import MarkdownParts, prepare_telegram
from takopi.router import AutoRouter, RunnerEntry
from takopi.telegram.render import prepare_telegram
from takopi.runners.codex import CodexRunner
from takopi.telegram import TelegramClient
from takopi.runners.mock import Advance, Emit, Raise, Return, ScriptRunner, Sleep, Wait
from takopi.runners.mock import Advance, Emit, Raise, Return, ScriptRunner, Wait
from takopi.transport import MessageRef, RenderedMessage, SendOptions
from tests.factories import action_completed, action_started
CODEX_ENGINE = EngineId("codex")
def _make_router(runner) -> AutoRouter:
return AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
default_engine=runner.engine,
)
def _patch_config(monkeypatch, config):
from pathlib import Path
@@ -34,6 +27,67 @@ def _patch_config(monkeypatch, config):
)
class _FakeTransport:
def __init__(self) -> None:
self._next_id = 1
self.send_calls: list[dict] = []
self.edit_calls: list[dict] = []
self.delete_calls: list[MessageRef] = []
async def send(
self,
*,
channel_id: int | str,
message: RenderedMessage,
options: SendOptions | None = None,
) -> MessageRef:
ref = MessageRef(channel_id=channel_id, message_id=self._next_id)
self._next_id += 1
self.send_calls.append(
{
"ref": ref,
"channel_id": channel_id,
"message": message,
"options": options,
}
)
return ref
async def edit(
self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True
) -> MessageRef:
self.edit_calls.append({"ref": ref, "message": message, "wait": wait})
return ref
async def delete(self, *, ref: MessageRef) -> bool:
self.delete_calls.append(ref)
return True
async def close(self) -> None:
return None
class _FakeClock:
def __init__(self, start: float = 0.0) -> None:
self._now = start
def __call__(self) -> float:
return self._now
def set(self, value: float) -> None:
self._now = value
def _return_runner(
*, answer: str = "ok", resume_value: str | None = None
) -> ScriptRunner:
return ScriptRunner(
[Return(answer=answer)],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
def test_load_and_validate_config_rejects_empty_token(monkeypatch) -> None:
from takopi import cli
@@ -125,250 +179,36 @@ def test_prepare_telegram_preserves_entities_on_truncate() -> None:
assert any(e.get("type") == "bold" for e in entities)
def test_strip_engine_command_inline() -> None:
text, engine = _strip_engine_command(
"/claude do it", engine_ids=("codex", "claude")
)
assert engine == "claude"
assert text == "do it"
def test_strip_engine_command_newline() -> None:
text, engine = _strip_engine_command(
"/codex\nhello", engine_ids=("codex", "claude")
)
assert engine == "codex"
assert text == "hello"
def test_strip_engine_command_ignores_unknown() -> None:
text, engine = _strip_engine_command("/unknown hi", engine_ids=("codex", "claude"))
assert engine is None
assert text == "/unknown hi"
def test_strip_engine_command_bot_suffix() -> None:
text, engine = _strip_engine_command(
"/claude@bunny_agent_bot hi", engine_ids=("claude",)
)
assert engine == "claude"
assert text == "hi"
def test_strip_engine_command_only_first_non_empty_line() -> None:
text, engine = _strip_engine_command(
"hello\n/claude hi", engine_ids=("codex", "claude")
)
assert engine is None
assert text == "hello\n/claude hi"
def test_build_bot_commands_includes_cancel_and_engine() -> None:
runner = ScriptRunner(
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
)
router = _make_router(runner)
commands = _build_bot_commands(router)
assert {"command": "cancel", "description": "cancel run"} in commands
assert any(cmd["command"] == "codex" for cmd in commands)
class _FakeBot:
def __init__(self) -> None:
self._next_id = 1
self.command_calls: list[dict] = []
self.send_calls: list[dict] = []
self.edit_calls: list[dict] = []
self.delete_calls: list[dict] = []
async def send_message(
self,
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
replace_message_id: int | None = None,
) -> dict:
_ = replace_message_id
self.send_calls.append(
{
"chat_id": chat_id,
"text": text,
"reply_to_message_id": reply_to_message_id,
"disable_notification": disable_notification,
"entities": entities,
"parse_mode": parse_mode,
}
)
msg_id = self._next_id
self._next_id += 1
return {"message_id": msg_id}
async def edit_message_text(
self,
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
wait: bool = True,
) -> dict:
_ = wait
self.edit_calls.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"entities": entities,
"parse_mode": parse_mode,
}
)
return {"message_id": message_id}
async def delete_message(
self,
chat_id: int,
message_id: int,
) -> bool:
self.delete_calls.append({"chat_id": chat_id, "message_id": message_id})
return True
async def set_my_commands(
self,
commands: list[dict],
*,
scope: dict | None = None,
language_code: str | None = None,
) -> bool:
self.command_calls.append(
{
"commands": commands,
"scope": scope,
"language_code": language_code,
}
)
return True
async def get_updates(
self,
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
_ = offset
_ = timeout_s
_ = allowed_updates
return []
async def close(self) -> None:
return None
async def get_me(self) -> dict | None:
return {"id": 1}
class _FakeClock:
def __init__(self, start: float = 0.0) -> None:
self._now = start
self._sleep_until: float | None = None
self._sleep_event: anyio.Event | None = None
self.sleep_calls = 0
def __call__(self) -> float:
return self._now
def set(self, value: float) -> None:
self._now = value
if self._sleep_until is None or self._sleep_event is None:
return
if self._sleep_until <= self._now:
self._sleep_event.set()
self._sleep_until = None
self._sleep_event = None
async def sleep(self, delay: float) -> None:
if delay <= 0:
await anyio.sleep(0)
return
self.sleep_calls += 1
self._sleep_until = self._now + delay
self._sleep_event = anyio.Event()
await self._sleep_event.wait()
def _queued_bot(
bot: "_FakeBot", *, clock: "_FakeClock | None" = None
) -> TelegramClient:
if clock is None:
return TelegramClient(
client=bot,
private_chat_rps=0.0,
group_chat_rps=0.0,
)
return TelegramClient(
client=bot,
clock=clock,
sleep=clock.sleep,
private_chat_rps=0.0,
group_chat_rps=0.0,
)
def _return_runner(
*, answer: str = "ok", resume_value: str | None = None
) -> ScriptRunner:
return ScriptRunner(
[Return(answer=answer)],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
@pytest.mark.anyio
async def test_final_notify_sends_loud_final_message() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="hi",
incoming=IncomingMessage(channel_id=123, message_id=10, text="hi"),
resume_token=None,
)
assert len(bot.send_calls) == 2
assert bot.send_calls[0]["disable_notification"] is True
assert bot.send_calls[1]["disable_notification"] is False
assert len(transport.send_calls) == 2
assert transport.send_calls[0]["options"].notify is False
assert transport.send_calls[1]["options"].notify is True
@pytest.mark.anyio
async def test_handle_message_strips_resume_line_from_prompt() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
resume = ResumeToken(engine=CODEX_ENGINE, value="sid")
text = "do this\n`codex resume sid`\nand that"
@@ -376,9 +216,7 @@ async def test_handle_message_strips_resume_line_from_prompt() -> None:
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text=text,
incoming=IncomingMessage(channel_id=123, message_id=10, text=text),
resume_token=resume,
)
@@ -390,38 +228,30 @@ async def test_handle_message_strips_resume_line_from_prompt() -> None:
@pytest.mark.anyio
async def test_long_final_message_edits_progress_message() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
runner = _return_runner(answer="x" * 10_000)
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=False,
startup_msg="",
)
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="hi",
incoming=IncomingMessage(channel_id=123, message_id=10, text="hi"),
resume_token=None,
)
assert len(bot.send_calls) == 1
assert bot.send_calls[0]["disable_notification"] is True
assert bot.edit_calls
assert "done" in bot.edit_calls[-1]["text"].lower()
assert len(transport.send_calls) == 1
assert transport.send_calls[0]["options"].notify is False
assert transport.edit_calls
assert "done" in transport.edit_calls[-1]["message"].text.lower()
@pytest.mark.anyio
async def test_progress_edits_are_rate_limited() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
async def test_progress_edits_are_best_effort() -> None:
transport = _FakeTransport()
clock = _FakeClock()
events: list[TakopiEvent] = [
action_started("item_0", "command", "echo 1"),
@@ -437,88 +267,28 @@ async def test_progress_edits_are_rate_limited() -> None:
engine=CODEX_ENGINE,
advance=clock.set,
)
cfg = BridgeConfig(
bot=_queued_bot(bot, clock=clock),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="hi",
incoming=IncomingMessage(channel_id=123, message_id=10, text="hi"),
resume_token=None,
clock=clock,
)
assert bot.edit_calls
assert "working" in bot.edit_calls[-1]["text"].lower()
@pytest.mark.anyio
async def test_progress_edits_do_not_sleep_again_without_new_events() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
clock = _FakeClock()
hold = anyio.Event()
events: list[TakopiEvent] = [
action_started("item_0", "command", "echo 1"),
action_started("item_1", "command", "echo 2"),
]
runner = ScriptRunner(
[
Emit(events[0], at=0.2),
Emit(events[1], at=0.4),
Wait(hold),
Return(answer="ok"),
],
engine=CODEX_ENGINE,
advance=clock.set,
)
cfg = BridgeConfig(
bot=_queued_bot(bot, clock=clock),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
async def run_handle_message() -> None:
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="hi",
resume_token=None,
clock=clock,
)
async with anyio.create_task_group() as tg:
tg.start_soon(run_handle_message)
for _ in range(100):
if bot.edit_calls:
break
await anyio.sleep(0)
assert bot.edit_calls
assert clock.sleep_calls == 0
assert clock._sleep_until is None
hold.set()
assert transport.edit_calls
assert all(call["wait"] is False for call in transport.edit_calls)
assert "working" in transport.edit_calls[-1]["message"].text.lower()
@pytest.mark.anyio
async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
clock = _FakeClock()
events: list[TakopiEvent] = [
action_started("item_0", "command", "echo ok"),
@@ -541,182 +311,32 @@ async def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
advance=clock.set,
resume_value=session_id,
)
cfg = BridgeConfig(
bot=_queued_bot(bot, clock=clock),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=42,
text="do it",
incoming=IncomingMessage(channel_id=123, message_id=42, text="do it"),
resume_token=None,
clock=clock,
)
assert bot.send_calls[0]["reply_to_message_id"] == 42
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()
assert len(bot.delete_calls) == 1
@pytest.mark.anyio
async def test_handle_cancel_without_reply_prompts_user() -> None:
from takopi.bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
msg = {"chat": {"id": 123}, "message_id": 10}
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1
assert "reply to the progress message" in bot.send_calls[0]["text"]
@pytest.mark.anyio
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
from takopi.bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"text": "no message id"},
}
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
@pytest.mark.anyio
async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
from takopi.bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
progress_id = 99
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
running_tasks: dict = {} # Progress message not in running_tasks
await _handle_cancel(cfg, msg, running_tasks)
assert len(bot.send_calls) == 1
assert "nothing is currently running" in bot.send_calls[0]["text"]
@pytest.mark.anyio
async def test_handle_cancel_cancels_running_task() -> None:
from takopi.bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
progress_id = 42
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
from takopi.bridge import RunningTask
running_task = RunningTask()
running_tasks = {progress_id: running_task}
await _handle_cancel(cfg, msg, running_tasks)
assert running_task.cancel_requested.is_set() is True
assert len(bot.send_calls) == 0 # No error message sent
@pytest.mark.anyio
async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
from takopi.bridge import BridgeConfig, _handle_cancel
bot = _FakeBot()
runner = _return_runner(answer="ok")
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
from takopi.bridge import RunningTask
task_first = RunningTask()
task_second = RunningTask()
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": 1},
}
running_tasks = {1: task_first, 2: task_second}
await _handle_cancel(cfg, msg, running_tasks)
assert task_first.cancel_requested.is_set() is True
assert task_second.cancel_requested.is_set() is False
assert len(bot.send_calls) == 0
def test_cancel_command_accepts_extra_text() -> None:
from takopi.bridge import _is_cancel_command
assert _is_cancel_command("/cancel now") is True
assert _is_cancel_command("/cancel@takopi please") is True
assert _is_cancel_command("/cancelled") is False
assert transport.send_calls[0]["options"].reply_to.message_id == 42
assert "starting" in transport.send_calls[0]["message"].text
assert "codex" in transport.send_calls[0]["message"].text
assert len(transport.edit_calls) >= 1
assert session_id in transport.send_calls[-1]["message"].text
assert "codex resume" in transport.send_calls[-1]["message"].text.lower()
assert transport.send_calls[-1]["options"].replace == transport.send_calls[0]["ref"]
@pytest.mark.anyio
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
hold = anyio.Event()
runner = ScriptRunner(
@@ -724,12 +344,10 @@ async def test_handle_message_cancelled_renders_cancelled_state() -> None:
engine=CODEX_ENGINE,
resume_value=session_id,
)
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
running_tasks: dict = {}
@@ -737,9 +355,9 @@ async def test_handle_message_cancelled_renders_cancelled_state() -> None:
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="do something",
incoming=IncomingMessage(
channel_id=123, message_id=10, text="do something"
),
resume_token=None,
running_tasks=running_tasks,
)
@@ -756,199 +374,37 @@ async def test_handle_message_cancelled_renders_cancelled_state() -> None:
await running_task.resume_ready.wait()
running_task.cancel_requested.set()
assert len(bot.send_calls) == 1 # Progress message
assert len(bot.edit_calls) >= 1
last_edit = bot.edit_calls[-1]["text"]
assert len(transport.send_calls) == 1 # Progress message
assert len(transport.edit_calls) >= 1
last_edit = transport.edit_calls[-1]["message"].text
assert "cancelled" in last_edit.lower()
assert session_id in last_edit
@pytest.mark.anyio
async def test_handle_message_error_preserves_resume_token() -> None:
from takopi.bridge import BridgeConfig, handle_message
bot = _FakeBot()
transport = _FakeTransport()
session_id = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
runner = ScriptRunner(
[Raise(RuntimeError("boom"))],
engine=CODEX_ENGINE,
resume_value=session_id,
)
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
startup_msg="",
)
await handle_message(
cfg,
runner=runner,
chat_id=123,
user_msg_id=10,
text="do something",
incoming=IncomingMessage(channel_id=123, message_id=10, text="do something"),
resume_token=None,
)
assert bot.edit_calls
last_edit = bot.edit_calls[-1]["text"]
assert transport.edit_calls
last_edit = transport.edit_calls[-1]["message"].text
assert "error" in last_edit.lower()
assert session_id in last_edit
assert "codex resume" in last_edit.lower()
@pytest.mark.anyio
async def test_send_with_resume_waits_for_token() -> None:
from takopi.bridge import RunningTask, _send_with_resume
bot = _FakeBot()
sent: list[tuple[int, int, str, ResumeToken | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
running_task = RunningTask()
async def trigger_resume() -> None:
await anyio.sleep(0)
running_task.resume = ResumeToken(engine=CODEX_ENGINE, value="abc123")
running_task.resume_ready.set()
async with anyio.create_task_group() as tg:
tg.start_soon(trigger_resume)
await _send_with_resume(
bot,
enqueue,
running_task,
123,
10,
"hello",
)
assert sent == [
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"))
]
@pytest.mark.anyio
async def test_send_with_resume_reports_when_missing() -> None:
from takopi.bridge import RunningTask, _send_with_resume
bot = _FakeBot()
sent: list[tuple[int, int, str, ResumeToken | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
running_task = RunningTask()
running_task.done.set()
await _send_with_resume(
bot,
enqueue,
running_task,
123,
10,
"hello",
)
assert sent == []
assert bot.send_calls
assert "resume token" in bot.send_calls[-1]["text"].lower()
@pytest.mark.anyio
async def test_run_main_loop_routes_reply_to_running_resume() -> None:
from takopi.bridge import BridgeConfig, run_main_loop
progress_ready = anyio.Event()
stop_polling = anyio.Event()
reply_ready = anyio.Event()
hold = anyio.Event()
class _BotWithProgress(_FakeBot):
def __init__(self) -> None:
super().__init__()
self.progress_id: int | None = None
async def send_message(
self,
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
replace_message_id: int | None = None,
) -> dict:
msg = await super().send_message(
chat_id=chat_id,
text=text,
reply_to_message_id=reply_to_message_id,
disable_notification=disable_notification,
entities=entities,
parse_mode=parse_mode,
replace_message_id=replace_message_id,
)
if self.progress_id is None and reply_to_message_id is not None:
self.progress_id = int(msg["message_id"])
progress_ready.set()
return msg
bot = _BotWithProgress()
resume_value = "abc123"
runner = ScriptRunner(
[Wait(hold), Sleep(0.05), Return(answer="ok")],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
cfg = BridgeConfig(
bot=_queued_bot(bot),
router=_make_router(runner),
chat_id=123,
final_notify=True,
startup_msg="",
)
async def poller(_cfg: BridgeConfig):
yield {
"message_id": 1,
"text": "first",
"chat": {"id": 123},
"from": {"id": 123},
}
await progress_ready.wait()
assert bot.progress_id is not None
reply_ready.set()
yield {
"message_id": 2,
"text": "followup",
"chat": {"id": 123},
"from": {"id": 123},
"reply_to_message": {"message_id": bot.progress_id},
}
await stop_polling.wait()
async with anyio.create_task_group() as tg:
tg.start_soon(run_main_loop, cfg, poller)
try:
with anyio.fail_after(2):
await reply_ready.wait()
await anyio.sleep(0)
hold.set()
with anyio.fail_after(2):
while len(runner.calls) < 2:
await anyio.sleep(0)
assert runner.calls[1][1] == ResumeToken(
engine=CODEX_ENGINE, value=resume_value
)
finally:
hold.set()
stop_polling.set()
tg.cancel_scope.cancel()
+63 -42
View File
@@ -2,18 +2,20 @@ from typing import cast
from types import SimpleNamespace
from pathlib import Path
from takopi.model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent
from takopi.render import (
ExecProgressRenderer,
from takopi.markdown import (
HARD_BREAK,
MarkdownFormatter,
STATUS,
action_status,
assemble_markdown_parts,
format_elapsed,
format_file_change_title,
render_event_cli,
render_markdown,
shorten,
)
from takopi.model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent
from takopi.progress import ProgressTracker
from takopi.telegram.render import render_markdown
from tests.factories import (
action_completed,
action_started,
@@ -118,19 +120,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, engine="codex"
)
tracker = ProgressTracker(engine="codex")
for evt in SAMPLE_EVENTS:
r.note_event(evt)
tracker.note_event(evt)
progress_parts = r.render_progress_parts(3.0)
state = tracker.snapshot(resume_formatter=_format_resume)
formatter = MarkdownFormatter(max_actions=5)
progress_parts = formatter.render_progress_parts(state, elapsed_s=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_parts = r.render_final_parts(3.0, "answer", status="done")
final_parts = formatter.render_final_parts(
state, elapsed_s=3.0, status="done", answer="answer"
)
final = assemble_markdown_parts(final_parts)
assert final.startswith("done · codex · 3s · step 2")
assert "✓ `bash -lc ls`" not in final
@@ -142,7 +146,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, engine="codex")
tracker = ProgressTracker(engine="codex")
events = [
action_completed(
f"item_{i}",
@@ -155,19 +159,23 @@ def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None:
]
for evt in events:
assert r.note_event(evt) is True
assert tracker.note_event(evt) is True
assert len(r.recent_actions) == 3
assert "echo 3" in r.recent_actions[0]
assert "echo 5" in r.recent_actions[-1]
state = tracker.snapshot()
formatter = MarkdownFormatter(max_actions=3, command_width=20)
parts = formatter.render_progress_parts(state, elapsed_s=0.0)
lines = parts.body.split(HARD_BREAK) if parts.body else []
assert len(lines) == 3
assert "echo 3" in lines[0]
assert "echo 5" in lines[-1]
mystery = SimpleNamespace(type="mystery")
assert r.note_event(cast(TakopiEvent, mystery)) is False
assert tracker.note_event(cast(TakopiEvent, mystery)) is False
def test_progress_renderer_renders_commands_in_markdown() -> None:
r = ExecProgressRenderer(max_actions=5, command_width=None, engine="codex")
tracker = ProgressTracker(engine="codex")
for i in (30, 31, 32):
r.note_event(
tracker.note_event(
action_completed(
f"item_{i}",
"command",
@@ -177,7 +185,9 @@ def test_progress_renderer_renders_commands_in_markdown() -> None:
)
)
md = assemble_markdown_parts(r.render_progress_parts(0.0))
state = tracker.snapshot()
formatter = MarkdownFormatter(max_actions=5, command_width=None)
md = assemble_markdown_parts(formatter.render_progress_parts(state, elapsed_s=0.0))
text, _ = render_markdown(md)
assert "✓ echo 30" in text
assert "✓ echo 31" in text
@@ -185,7 +195,7 @@ def test_progress_renderer_renders_commands_in_markdown() -> None:
def test_progress_renderer_handles_duplicate_action_ids() -> None:
r = ExecProgressRenderer(max_actions=5, engine="codex")
tracker = ProgressTracker(engine="codex")
events = [
action_started("dup", "command", "echo first"),
action_completed(
@@ -206,17 +216,19 @@ def test_progress_renderer_handles_duplicate_action_ids() -> None:
]
for evt in events:
assert r.note_event(evt) is True
assert tracker.note_event(evt) is True
assert len(r.recent_actions) == 2
assert r.recent_actions[0].startswith("")
assert "echo first" in r.recent_actions[0]
assert r.recent_actions[1].startswith("")
assert "echo second" in r.recent_actions[1]
state = tracker.snapshot()
formatter = MarkdownFormatter(max_actions=5)
parts = formatter.render_progress_parts(state, elapsed_s=0.0)
lines = parts.body.split(HARD_BREAK) if parts.body else []
assert len(lines) == 1
assert lines[0].startswith("")
assert "echo second" in lines[0]
def test_progress_renderer_collapses_action_updates() -> None:
r = ExecProgressRenderer(max_actions=5, engine="codex")
tracker = ProgressTracker(engine="codex")
events = [
action_started("a-1", "command", "echo one"),
action_started("a-1", "command", "echo two"),
@@ -230,12 +242,16 @@ def test_progress_renderer_collapses_action_updates() -> None:
]
for evt in events:
assert r.note_event(evt) is True
assert tracker.note_event(evt) is True
assert r.action_count == 1
assert len(r.recent_actions) == 1
assert r.recent_actions[0].startswith("")
assert "echo two" in r.recent_actions[0]
assert tracker.action_count == 1
state = tracker.snapshot()
formatter = MarkdownFormatter(max_actions=5)
parts = formatter.render_progress_parts(state, elapsed_s=0.0)
lines = parts.body.split(HARD_BREAK) if parts.body else []
assert len(lines) == 1
assert lines[0].startswith("")
assert "echo two" in lines[0]
def test_progress_renderer_deterministic_output() -> None:
@@ -249,16 +265,18 @@ def test_progress_renderer_deterministic_output() -> None:
detail={"exit_code": 0},
),
]
r1 = ExecProgressRenderer(max_actions=5, engine="codex")
r2 = ExecProgressRenderer(max_actions=5, engine="codex")
t1 = ProgressTracker(engine="codex")
t2 = ProgressTracker(engine="codex")
for evt in events:
r1.note_event(evt)
r2.note_event(evt)
t1.note_event(evt)
t2.note_event(evt)
f1 = MarkdownFormatter(max_actions=5)
f2 = MarkdownFormatter(max_actions=5)
assert assemble_markdown_parts(
r1.render_progress_parts(1.0)
) == assemble_markdown_parts(r2.render_progress_parts(1.0))
f1.render_progress_parts(t1.snapshot(), elapsed_s=1.0)
) == assemble_markdown_parts(f2.render_progress_parts(t2.snapshot(), elapsed_s=1.0))
def test_format_elapsed_branches() -> None:
@@ -319,9 +337,9 @@ def test_render_event_cli_ignores_turn_actions() -> None:
def test_progress_renderer_ignores_missing_action_id() -> None:
renderer = ExecProgressRenderer(engine="codex")
tracker = ProgressTracker(engine="codex")
resume = ResumeToken(engine="codex", value="abc")
renderer.note_event(StartedEvent(engine="codex", resume=resume, title="Session"))
tracker.note_event(StartedEvent(engine="codex", resume=resume, title="Session"))
event = ActionEvent(
engine="codex",
@@ -329,7 +347,10 @@ def test_progress_renderer_ignores_missing_action_id() -> None:
phase="started",
ok=None,
)
assert renderer.note_event(event) is False
assert tracker.note_event(event) is False
header = assemble_markdown_parts(renderer.render_progress_parts(0.0))
formatter = MarkdownFormatter()
header = assemble_markdown_parts(
formatter.render_progress_parts(tracker.snapshot(), elapsed_s=0.0)
)
assert header.startswith("working · codex · 0s")
+2 -1
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from pathlib import Path
from takopi import engines, onboarding
from takopi import engines
from takopi.telegram import onboarding
def test_check_setup_marks_missing_codex(monkeypatch, tmp_path: Path) -> None:
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from takopi import onboarding
from takopi.telegram import onboarding
from takopi.backends import EngineBackend
+1 -1
View File
@@ -1,4 +1,4 @@
from takopi.render import render_markdown
from takopi.telegram.render import render_markdown
def test_render_markdown_basic_entities() -> None:
+575
View File
@@ -0,0 +1,575 @@
import anyio
import pytest
from takopi.telegram.bridge import (
TelegramBridgeConfig,
TelegramTransport,
_build_bot_commands,
_handle_cancel,
_is_cancel_command,
_send_with_resume,
_strip_engine_command,
run_main_loop,
)
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
from takopi.markdown import MarkdownPresenter
from takopi.model import EngineId, ResumeToken
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner, Sleep, Wait
from takopi.transport import MessageRef, RenderedMessage, SendOptions
CODEX_ENGINE = EngineId("codex")
def _make_router(runner) -> AutoRouter:
return AutoRouter(
entries=[RunnerEntry(engine=runner.engine, runner=runner)],
default_engine=runner.engine,
)
class _FakeTransport:
def __init__(self, progress_ready: anyio.Event | None = None) -> None:
self._next_id = 1
self.send_calls: list[dict] = []
self.edit_calls: list[dict] = []
self.delete_calls: list[MessageRef] = []
self.progress_ready = progress_ready
self.progress_ref: MessageRef | None = None
async def send(
self,
*,
channel_id: int | str,
message: RenderedMessage,
options: SendOptions | None = None,
) -> MessageRef:
ref = MessageRef(channel_id=channel_id, message_id=self._next_id)
self._next_id += 1
self.send_calls.append(
{
"ref": ref,
"channel_id": channel_id,
"message": message,
"options": options,
}
)
if (
self.progress_ref is None
and options is not None
and options.reply_to is not None
and options.notify is False
):
self.progress_ref = ref
if self.progress_ready is not None:
self.progress_ready.set()
return ref
async def edit(
self, *, ref: MessageRef, message: RenderedMessage, wait: bool = True
) -> MessageRef:
self.edit_calls.append({"ref": ref, "message": message, "wait": wait})
return ref
async def delete(self, *, ref: MessageRef) -> bool:
self.delete_calls.append(ref)
return True
async def close(self) -> None:
return None
class _FakeBot:
def __init__(self) -> None:
self.command_calls: list[dict] = []
self.send_calls: list[dict] = []
self.edit_calls: list[dict] = []
self.delete_calls: list[dict] = []
async def get_updates(
self,
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
_ = offset
_ = timeout_s
_ = allowed_updates
return []
async def send_message(
self,
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
replace_message_id: int | None = None,
) -> dict:
self.send_calls.append(
{
"chat_id": chat_id,
"text": text,
"reply_to_message_id": reply_to_message_id,
"disable_notification": disable_notification,
"entities": entities,
"parse_mode": parse_mode,
"replace_message_id": replace_message_id,
}
)
return {"message_id": 1}
async def edit_message_text(
self,
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
wait: bool = True,
) -> dict:
self.edit_calls.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"entities": entities,
"parse_mode": parse_mode,
"wait": wait,
}
)
return {"message_id": message_id}
async def delete_message(self, chat_id: int, message_id: int) -> bool:
self.delete_calls.append({"chat_id": chat_id, "message_id": message_id})
return True
async def set_my_commands(
self,
commands: list[dict],
*,
scope: dict | None = None,
language_code: str | None = None,
) -> bool:
self.command_calls.append(
{
"commands": commands,
"scope": scope,
"language_code": language_code,
}
)
return True
async def get_me(self) -> dict | None:
return {"id": 1}
async def close(self) -> None:
return None
def _make_cfg(
transport: _FakeTransport, runner: ScriptRunner | None = None
) -> TelegramBridgeConfig:
if runner is None:
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
return TelegramBridgeConfig(
bot=_FakeBot(),
router=_make_router(runner),
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
)
def test_strip_engine_command_inline() -> None:
text, engine = _strip_engine_command(
"/claude do it", engine_ids=("codex", "claude")
)
assert engine == "claude"
assert text == "do it"
def test_strip_engine_command_newline() -> None:
text, engine = _strip_engine_command(
"/codex\nhello", engine_ids=("codex", "claude")
)
assert engine == "codex"
assert text == "hello"
def test_strip_engine_command_ignores_unknown() -> None:
text, engine = _strip_engine_command("/unknown hi", engine_ids=("codex", "claude"))
assert engine is None
assert text == "/unknown hi"
def test_strip_engine_command_bot_suffix() -> None:
text, engine = _strip_engine_command(
"/claude@bunny_agent_bot hi", engine_ids=("claude",)
)
assert engine == "claude"
assert text == "hi"
def test_strip_engine_command_only_first_non_empty_line() -> None:
text, engine = _strip_engine_command(
"hello\n/claude hi", engine_ids=("codex", "claude")
)
assert engine is None
assert text == "hello\n/claude hi"
def test_build_bot_commands_includes_cancel_and_engine() -> None:
runner = ScriptRunner(
[Return(answer="ok")], engine=CODEX_ENGINE, resume_value="sid"
)
router = _make_router(runner)
commands = _build_bot_commands(router)
assert {"command": "cancel", "description": "cancel run"} in commands
assert any(cmd["command"] == "codex" for cmd in commands)
@pytest.mark.anyio
async def test_telegram_transport_passes_replace_and_wait() -> None:
bot = _FakeBot()
transport = TelegramTransport(bot)
reply = MessageRef(channel_id=123, message_id=10)
replace = MessageRef(channel_id=123, message_id=11)
await transport.send(
channel_id=123,
message=RenderedMessage(text="hello"),
options=SendOptions(reply_to=reply, notify=True, replace=replace),
)
assert bot.send_calls
assert bot.send_calls[0]["replace_message_id"] == 11
await transport.edit(
ref=replace,
message=RenderedMessage(text="edit"),
wait=False,
)
assert bot.edit_calls
assert bot.edit_calls[0]["wait"] is False
@pytest.mark.anyio
async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
class _OutboxBot:
def __init__(self) -> None:
self.edit_calls: list[dict[str, object]] = []
async def get_updates(
self,
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
return None
async def send_message(
self,
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
replace_message_id: int | None = None,
) -> dict | None:
return None
async def edit_message_text(
self,
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
parse_mode: str | None = None,
*,
wait: bool = True,
) -> dict | None:
self.edit_calls.append(
{
"chat_id": chat_id,
"message_id": message_id,
"text": text,
"entities": entities,
"parse_mode": parse_mode,
"wait": wait,
}
)
if not wait:
return None
return {"message_id": message_id}
async def delete_message(
self,
chat_id: int,
message_id: int,
) -> bool:
return False
async def set_my_commands(
self,
commands: list[dict[str, object]],
*,
scope: dict[str, object] | None = None,
language_code: str | None = None,
) -> bool:
return False
async def get_me(self) -> dict | None:
return None
async def close(self) -> None:
return None
bot = _OutboxBot()
transport = TelegramTransport(bot)
ref = MessageRef(channel_id=123, message_id=1)
result = await transport.edit(
ref=ref,
message=RenderedMessage(text="edit"),
wait=False,
)
assert result == ref
assert bot.edit_calls
assert bot.edit_calls[0]["wait"] is False
@pytest.mark.anyio
async def test_handle_cancel_without_reply_prompts_user() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
msg = {"chat": {"id": 123}, "message_id": 10}
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "reply to the progress message" in transport.send_calls[0]["message"].text
@pytest.mark.anyio
async def test_handle_cancel_with_no_progress_message_says_nothing_running() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"text": "no message id"},
}
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text
@pytest.mark.anyio
async def test_handle_cancel_with_finished_task_says_nothing_running() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
progress_id = 99
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
running_tasks: dict = {}
await _handle_cancel(cfg, msg, running_tasks)
assert len(transport.send_calls) == 1
assert "nothing is currently running" in transport.send_calls[0]["message"].text
@pytest.mark.anyio
async def test_handle_cancel_cancels_running_task() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
progress_id = 42
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": progress_id},
}
running_task = RunningTask()
running_tasks = {MessageRef(channel_id=123, message_id=progress_id): running_task}
await _handle_cancel(cfg, msg, running_tasks)
assert running_task.cancel_requested.is_set() is True
assert len(transport.send_calls) == 0 # No error message sent
@pytest.mark.anyio
async def test_handle_cancel_only_cancels_matching_progress_message() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
task_first = RunningTask()
task_second = RunningTask()
msg = {
"chat": {"id": 123},
"message_id": 10,
"reply_to_message": {"message_id": 1},
}
running_tasks = {
MessageRef(channel_id=123, message_id=1): task_first,
MessageRef(channel_id=123, message_id=2): task_second,
}
await _handle_cancel(cfg, msg, running_tasks)
assert task_first.cancel_requested.is_set() is True
assert task_second.cancel_requested.is_set() is False
assert len(transport.send_calls) == 0
def test_cancel_command_accepts_extra_text() -> None:
assert _is_cancel_command("/cancel now") is True
assert _is_cancel_command("/cancel@takopi please") is True
assert _is_cancel_command("/cancelled") is False
@pytest.mark.anyio
async def test_send_with_resume_waits_for_token() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
running_task = RunningTask()
async def trigger_resume() -> None:
await anyio.sleep(0)
running_task.resume = ResumeToken(engine=CODEX_ENGINE, value="abc123")
running_task.resume_ready.set()
async with anyio.create_task_group() as tg:
tg.start_soon(trigger_resume)
await _send_with_resume(
cfg,
enqueue,
running_task,
123,
10,
"hello",
)
assert sent == [
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"))
]
assert transport.send_calls == []
@pytest.mark.anyio
async def test_send_with_resume_reports_when_missing() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken | None]] = []
async def enqueue(
chat_id: int, user_msg_id: int, text: str, resume: ResumeToken
) -> None:
sent.append((chat_id, user_msg_id, text, resume))
running_task = RunningTask()
running_task.done.set()
await _send_with_resume(
cfg,
enqueue,
running_task,
123,
10,
"hello",
)
assert sent == []
assert transport.send_calls
assert "resume token" in transport.send_calls[-1]["message"].text.lower()
@pytest.mark.anyio
async def test_run_main_loop_routes_reply_to_running_resume() -> None:
progress_ready = anyio.Event()
stop_polling = anyio.Event()
reply_ready = anyio.Event()
hold = anyio.Event()
transport = _FakeTransport(progress_ready=progress_ready)
bot = _FakeBot()
resume_value = "abc123"
runner = ScriptRunner(
[Wait(hold), Sleep(0.05), Return(answer="ok")],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
cfg = TelegramBridgeConfig(
bot=bot,
router=_make_router(runner),
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
)
async def poller(_cfg: TelegramBridgeConfig):
yield {
"message_id": 1,
"text": "first",
"chat": {"id": 123},
"from": {"id": 123},
}
await progress_ready.wait()
assert transport.progress_ref is not None
reply_ready.set()
yield {
"message_id": 2,
"text": "followup",
"chat": {"id": 123},
"from": {"id": 123},
"reply_to_message": {"message_id": transport.progress_ref.message_id},
}
await stop_polling.wait()
async with anyio.create_task_group() as tg:
tg.start_soon(run_main_loop, cfg, poller)
try:
with anyio.fail_after(2):
await reply_ready.wait()
await anyio.sleep(0)
hold.set()
with anyio.fail_after(2):
while len(runner.calls) < 2:
await anyio.sleep(0)
assert runner.calls[1][1] == ResumeToken(
engine=CODEX_ENGINE, value=resume_value
)
finally:
hold.set()
stop_polling.set()
tg.cancel_scope.cancel()
+1 -1
View File
@@ -2,7 +2,7 @@ import httpx
import pytest
from takopi.logging import setup_logging
from takopi.telegram import TelegramClient, TelegramRetryAfter
from takopi.telegram.client import TelegramClient, TelegramRetryAfter
@pytest.mark.anyio
+1 -1
View File
@@ -1,7 +1,7 @@
import anyio
import pytest
from takopi.telegram import TelegramClient, TelegramRetryAfter
from takopi.telegram.client import TelegramClient, TelegramRetryAfter
class _FakeBot: