test: add offline bridge and client coverage
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from codex_telegram_bridge.exec_bridge import extract_session_id, truncate_for_telegram
|
from codex_telegram_bridge.exec_bridge import (
|
||||||
|
extract_session_id,
|
||||||
|
prepare_telegram,
|
||||||
|
resolve_resume_session,
|
||||||
|
truncate_for_telegram,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_extract_session_id_finds_uuid_v7() -> None:
|
def test_extract_session_id_finds_uuid_v7() -> None:
|
||||||
@@ -17,6 +22,38 @@ def test_extract_session_id_requires_resume_line() -> None:
|
|||||||
assert extract_session_id(text) is None
|
assert extract_session_id(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_session_id_uses_last_resume_line() -> None:
|
||||||
|
uuid_first = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
|
||||||
|
uuid_last = "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
text = f"resume: `{uuid_first}`\n\nresume: `{uuid_last}`"
|
||||||
|
|
||||||
|
assert extract_session_id(text) == uuid_last
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_session_id_ignores_malformed_resume_line() -> None:
|
||||||
|
text = "resume: not-a-uuid"
|
||||||
|
|
||||||
|
assert extract_session_id(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_resume_session_prefers_message_text() -> None:
|
||||||
|
uuid_message = "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
uuid_reply = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolve_resume_session(
|
||||||
|
f"resume: `{uuid_message}`", f"resume: `{uuid_reply}`"
|
||||||
|
)
|
||||||
|
== uuid_message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_resume_session_uses_reply_when_missing() -> None:
|
||||||
|
uuid_reply = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
|
||||||
|
|
||||||
|
assert resolve_resume_session("no resume here", f"resume: `{uuid_reply}`") == uuid_reply
|
||||||
|
|
||||||
|
|
||||||
def test_truncate_for_telegram_preserves_resume_line() -> None:
|
def test_truncate_for_telegram_preserves_resume_line() -> None:
|
||||||
uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
|
uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
|
||||||
md = ("x" * 10_000) + f"\nresume: `{uuid}`"
|
md = ("x" * 10_000) + f"\nresume: `{uuid}`"
|
||||||
@@ -28,6 +65,24 @@ def test_truncate_for_telegram_preserves_resume_line() -> None:
|
|||||||
assert out.rstrip().endswith(f"resume: `{uuid}`")
|
assert out.rstrip().endswith(f"resume: `{uuid}`")
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_for_telegram_keeps_last_non_empty_line() -> None:
|
||||||
|
md = "intro\n\n" + ("x" * 500) + "\nlast line"
|
||||||
|
|
||||||
|
out = truncate_for_telegram(md, 120)
|
||||||
|
|
||||||
|
assert len(out) <= 120
|
||||||
|
assert out.rstrip().endswith("last line")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class _FakeBot:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
@@ -91,6 +146,43 @@ class _FakeRunner:
|
|||||||
return ("019b66fc-64c2-7a71-81cd-081c504cfeb2", self._answer, self._saw_agent_message)
|
return ("019b66fc-64c2-7a71-81cd-081c504cfeb2", self._answer, self._saw_agent_message)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRunnerWithEvents:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
events: list[dict],
|
||||||
|
times: list[float],
|
||||||
|
clock: _FakeClock,
|
||||||
|
answer: str = "ok",
|
||||||
|
session_id: str = "019b66fc-64c2-7a71-81cd-081c504cfeb2",
|
||||||
|
) -> None:
|
||||||
|
self._events = events
|
||||||
|
self._times = times
|
||||||
|
self._clock = clock
|
||||||
|
self._answer = answer
|
||||||
|
self._session_id = session_id
|
||||||
|
|
||||||
|
async def run_serialized(self, *_args, **kwargs) -> tuple[str, str, bool]:
|
||||||
|
on_event = kwargs.get("on_event")
|
||||||
|
if on_event is not None:
|
||||||
|
for when, event in zip(self._times, self._events, strict=False):
|
||||||
|
self._clock.set(when)
|
||||||
|
await on_event(event)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return (self._session_id, self._answer, True)
|
||||||
|
|
||||||
|
|
||||||
def test_final_notify_sends_loud_final_message() -> None:
|
def test_final_notify_sends_loud_final_message() -> None:
|
||||||
from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message
|
from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message
|
||||||
|
|
||||||
@@ -147,3 +239,130 @@ def test_new_final_message_forces_notification_when_too_long_to_edit() -> None:
|
|||||||
assert len(bot.send_calls) == 2
|
assert len(bot.send_calls) == 2
|
||||||
assert bot.send_calls[0]["disable_notification"] is True
|
assert bot.send_calls[0]["disable_notification"] is True
|
||||||
assert bot.send_calls[1]["disable_notification"] is False
|
assert bot.send_calls[1]["disable_notification"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_edits_are_rate_limited() -> None:
|
||||||
|
from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message
|
||||||
|
|
||||||
|
bot = _FakeBot()
|
||||||
|
clock = _FakeClock()
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"type": "item.started",
|
||||||
|
"item": {
|
||||||
|
"id": "item_0",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": "echo 1",
|
||||||
|
"status": "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item.completed",
|
||||||
|
"item": {
|
||||||
|
"id": "item_0",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": "echo 1",
|
||||||
|
"exit_code": 0,
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item.started",
|
||||||
|
"item": {
|
||||||
|
"id": "item_1",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": "echo 2",
|
||||||
|
"status": "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
runner = _FakeRunnerWithEvents(
|
||||||
|
events=events,
|
||||||
|
times=[0.2, 0.4, 1.2],
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
cfg = BridgeConfig(
|
||||||
|
bot=bot, # type: ignore[arg-type]
|
||||||
|
runner=runner, # type: ignore[arg-type]
|
||||||
|
chat_id=123,
|
||||||
|
final_notify=True,
|
||||||
|
startup_msg="",
|
||||||
|
max_concurrency=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
_handle_message(
|
||||||
|
cfg,
|
||||||
|
chat_id=123,
|
||||||
|
user_msg_id=10,
|
||||||
|
text="hi",
|
||||||
|
resume_session=None,
|
||||||
|
clock=clock,
|
||||||
|
progress_edit_every=1.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(bot.edit_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_flow_sends_progress_edits_and_final_resume() -> None:
|
||||||
|
from codex_telegram_bridge.exec_bridge import BridgeConfig, _handle_message
|
||||||
|
|
||||||
|
bot = _FakeBot()
|
||||||
|
clock = _FakeClock()
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"type": "item.started",
|
||||||
|
"item": {
|
||||||
|
"id": "item_0",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": "echo ok",
|
||||||
|
"status": "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "item.completed",
|
||||||
|
"item": {
|
||||||
|
"id": "item_0",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": "echo ok",
|
||||||
|
"exit_code": 0,
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
session_id = "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
runner = _FakeRunnerWithEvents(
|
||||||
|
events=events,
|
||||||
|
times=[0.0, 2.1],
|
||||||
|
clock=clock,
|
||||||
|
answer="done",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
cfg = BridgeConfig(
|
||||||
|
bot=bot, # type: ignore[arg-type]
|
||||||
|
runner=runner, # type: ignore[arg-type]
|
||||||
|
chat_id=123,
|
||||||
|
final_notify=True,
|
||||||
|
startup_msg="",
|
||||||
|
max_concurrency=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
_handle_message(
|
||||||
|
cfg,
|
||||||
|
chat_id=123,
|
||||||
|
user_msg_id=42,
|
||||||
|
text="do it",
|
||||||
|
resume_session=None,
|
||||||
|
clock=clock,
|
||||||
|
progress_edit_every=1.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bot.send_calls[0]["reply_to_message_id"] == 42
|
||||||
|
assert "working" in bot.send_calls[0]["text"]
|
||||||
|
assert len(bot.edit_calls) >= 1
|
||||||
|
assert session_id in bot.send_calls[-1]["text"]
|
||||||
|
assert "resume:" in bot.send_calls[-1]["text"].lower()
|
||||||
|
assert len(bot.delete_calls) == 1
|
||||||
|
|||||||
@@ -75,3 +75,28 @@ def test_progress_renderer_renders_progress_and_final() -> None:
|
|||||||
assert "running:" not in final
|
assert "running:" not in final
|
||||||
assert "ran:" not in final
|
assert "ran:" not in final
|
||||||
assert final.endswith("answer")
|
assert final.endswith("answer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_renderer_clamps_actions_and_ignores_unknown() -> None:
|
||||||
|
r = ExecProgressRenderer(max_actions=3, command_width=20)
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"type": "item.completed",
|
||||||
|
"item": {
|
||||||
|
"id": f"item_{i}",
|
||||||
|
"type": "command_execution",
|
||||||
|
"command": f"echo {i}",
|
||||||
|
"exit_code": 0,
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i in range(6)
|
||||||
|
]
|
||||||
|
|
||||||
|
for evt in events:
|
||||||
|
assert r.note_event(evt) is True
|
||||||
|
|
||||||
|
assert len(r.recent_actions) == 3
|
||||||
|
assert r.recent_actions[0].startswith("3 ")
|
||||||
|
assert r.recent_actions[-1].startswith("5 ")
|
||||||
|
assert r.note_event({"type": "mystery"}) is False
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from codex_telegram_bridge.exec_bridge import CodexExecRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_serialized_serializes_same_session() -> None:
|
||||||
|
runner = CodexExecRunner(codex_cmd="codex", workspace=None, extra_args=[])
|
||||||
|
gate = asyncio.Event()
|
||||||
|
in_flight = 0
|
||||||
|
max_in_flight = 0
|
||||||
|
|
||||||
|
async def run_stub(*_args, **_kwargs):
|
||||||
|
nonlocal in_flight, max_in_flight
|
||||||
|
in_flight += 1
|
||||||
|
max_in_flight = max(max_in_flight, in_flight)
|
||||||
|
await gate.wait()
|
||||||
|
in_flight -= 1
|
||||||
|
return ("sid", "ok", True)
|
||||||
|
|
||||||
|
runner.run = run_stub # type: ignore[assignment]
|
||||||
|
|
||||||
|
async def run_test() -> None:
|
||||||
|
t1 = asyncio.create_task(runner.run_serialized("a", "sid"))
|
||||||
|
t2 = asyncio.create_task(runner.run_serialized("b", "sid"))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate.set()
|
||||||
|
await asyncio.gather(t1, t2)
|
||||||
|
|
||||||
|
asyncio.run(run_test())
|
||||||
|
|
||||||
|
assert max_in_flight == 1
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from codex_telegram_bridge.logging import RedactTokenFilter
|
||||||
|
from codex_telegram_bridge.telegram_client import TelegramClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_429_retry_after_calls_sleep() -> None:
|
||||||
|
calls: list[int] = []
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleeps.append(seconds)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls.append(1)
|
||||||
|
if len(calls) == 1:
|
||||||
|
return httpx.Response(
|
||||||
|
429,
|
||||||
|
json={
|
||||||
|
"ok": False,
|
||||||
|
"description": "retry",
|
||||||
|
"parameters": {"retry_after": 3},
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"ok": True, "result": {"message_id": 1}},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
async def run() -> dict:
|
||||||
|
client = httpx.AsyncClient(transport=transport)
|
||||||
|
try:
|
||||||
|
tg = TelegramClient("123:abcDEF_ghij", client=client, sleep=fake_sleep)
|
||||||
|
return await tg._post("sendMessage", {"chat_id": 1, "text": "hi"})
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
result = asyncio.run(run())
|
||||||
|
|
||||||
|
assert result == {"message_id": 1}
|
||||||
|
assert sleeps == [3]
|
||||||
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_token_in_logs_on_http_error(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
token = "123:abcDEF_ghij"
|
||||||
|
redactor = RedactTokenFilter()
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addFilter(redactor)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, text="oops", request=request)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
client = httpx.AsyncClient(transport=transport)
|
||||||
|
try:
|
||||||
|
tg = TelegramClient(token, client=client)
|
||||||
|
await tg._post("getUpdates", {"timeout": 1})
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
caplog.set_level(logging.ERROR)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
root_logger.removeFilter(redactor)
|
||||||
|
|
||||||
|
assert token not in caplog.text
|
||||||
|
assert "bot[REDACTED]" in caplog.text
|
||||||
Reference in New Issue
Block a user