chore: move to top level

This commit is contained in:
banteg
2025-12-29 15:17:32 +04:00
parent a31f8c2459
commit dee204fac9
22 changed files with 102 additions and 236 deletions
+5
View File
@@ -0,0 +1,5 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+37
View File
File diff suppressed because one or more lines are too long
+368
View File
@@ -0,0 +1,368 @@
import asyncio
from takopi.exec_bridge import (
extract_session_id,
prepare_telegram,
resolve_resume_session,
truncate_for_telegram,
)
def test_extract_session_id_finds_uuid_v7() -> None:
uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
text = f"resume: `{uuid}`"
assert extract_session_id(text) == uuid
def test_extract_session_id_requires_resume_line() -> None:
uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
text = f"here is a uuid {uuid}"
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:
uuid = "019b66fc-64c2-7a71-81cd-081c504cfeb2"
md = ("x" * 10_000) + f"\nresume: `{uuid}`"
out = truncate_for_telegram(md, 400)
assert len(out) <= 400
assert uuid in out
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:
def __init__(self) -> None:
self._next_id = 1
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,
) -> 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,
}
)
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,
) -> dict:
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
class _FakeRunner:
def __init__(self, *, answer: str, saw_agent_message: bool = True) -> None:
self._answer = answer
self._saw_agent_message = saw_agent_message
async def run_serialized(self, *_args, **_kwargs) -> tuple[str, str, bool]:
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:
from takopi.exec_bridge import BridgeConfig, _handle_message
bot = _FakeBot()
runner = _FakeRunner(answer="ok")
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,
)
)
assert len(bot.send_calls) == 2
assert bot.send_calls[0]["disable_notification"] is True
assert bot.send_calls[1]["disable_notification"] is False
def test_new_final_message_forces_notification_when_too_long_to_edit() -> None:
from takopi.exec_bridge import BridgeConfig, _handle_message
bot = _FakeBot()
runner = _FakeRunner(answer="x" * 10_000)
cfg = BridgeConfig(
bot=bot, # type: ignore[arg-type]
runner=runner, # type: ignore[arg-type]
chat_id=123,
final_notify=False,
startup_msg="",
max_concurrency=1,
)
asyncio.run(
_handle_message(
cfg,
chat_id=123,
user_msg_id=10,
text="hi",
resume_session=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
def test_progress_edits_are_rate_limited() -> None:
from takopi.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 takopi.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
+102
View File
@@ -0,0 +1,102 @@
import json
from pathlib import Path
from takopi.exec_render import ExecProgressRenderer, render_event_cli
def _loads(lines: str) -> list[dict]:
return [json.loads(line) for line in lines.strip().splitlines() if line.strip()]
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "codex.jsonl"
SAMPLE_STREAM = """
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}}
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}}
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\\nAGENTS.md\\nCHANGELOG.md\\ncliff.toml\\ncodex-cli\\ncodex-rs\\ndocs\\nexamples\\nflake.lock\\nflake.nix\\nLICENSE\\nnode_modules\\nNOTICE\\npackage.json\\npnpm-lock.yaml\\npnpm-workspace.yaml\\nPNPM.md\\nREADME.md\\nscripts\\nsdk\\ntmp\\n","exit_code":0,"status":"completed"}}
{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}}
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Yep — theres a `README.md` in the repository root."}}
{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}
"""
def test_render_event_cli_sample_stream() -> None:
last_turn = None
out: list[str] = []
for evt in _loads(SAMPLE_STREAM):
last_turn, lines = render_event_cli(evt, last_turn)
out.extend(lines)
assert out == [
"thread started",
"turn started",
"0. **Searching for README files**",
"1. ▸ `bash -lc ls`",
"1. ✓ `bash -lc ls`",
"2. **Checking repository root for README**",
"assistant:",
" Yep — theres a `README.md` in the repository root.",
"turn completed",
]
def test_render_event_cli_real_run_fixture() -> None:
events = _loads(FIXTURE_PATH.read_text(encoding="utf-8"))
last_turn = None
out: list[str] = []
for evt in events:
last_turn, lines = render_event_cli(evt, last_turn)
out.extend(lines)
print("\n".join(out))
assert out[0] == "thread started"
assert "turn started" in out
assert any(line.startswith("0. ▸ `") for line in out)
assert any(line.startswith("0. ✓ `") for line in out)
assert "assistant:" in out
assert any("takopi" in line for line in out)
assert out[-1] == "turn completed"
def test_progress_renderer_renders_progress_and_final() -> None:
r = ExecProgressRenderer(max_actions=5)
for evt in _loads(SAMPLE_STREAM):
r.note_event(evt)
progress = r.render_progress(3.0)
assert progress.startswith("working · 3s · item 3")
assert "1. ✓ `bash -lc ls`" in progress
final = r.render_final(3.0, "answer", status="done")
assert final.startswith("done · 3s · item 3")
assert "running:" not in final
assert "ran:" not in final
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
+31
View File
@@ -0,0 +1,31 @@
import asyncio
from takopi.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
+22
View File
@@ -0,0 +1,22 @@
from takopi.rendering import render_markdown
def test_render_markdown_basic_entities() -> None:
text, entities = render_markdown("**bold** and `code`")
assert text == "bold and code\n\n"
assert entities == [
{"type": "bold", "offset": 0, "length": 4},
{"type": "code", "offset": 9, "length": 4},
]
def test_render_markdown_code_fence_language_is_string() -> None:
text, entities = render_markdown("```py\nprint('x')\n```")
assert text == "print('x')\n\n"
assert entities is not None
assert any(
e.get("type") == "pre" and e.get("language") == "py" for e in entities
)
assert any(e.get("type") == "code" for e in entities)
+29
View File
@@ -0,0 +1,29 @@
import asyncio
import sys
from takopi import exec_bridge
def test_manage_subprocess_kills_when_terminate_times_out(monkeypatch) -> None:
async def fake_wait_for(awaitable, *args, **kwargs):
if hasattr(awaitable, "close"):
awaitable.close()
elif hasattr(awaitable, "cancel"):
awaitable.cancel()
raise asyncio.TimeoutError
monkeypatch.setattr(exec_bridge.asyncio, "wait_for", fake_wait_for)
async def run() -> int | None:
async with exec_bridge.manage_subprocess(
sys.executable,
"-c",
"import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)",
) as proc:
assert proc.returncode is None
return proc.returncode
rc = asyncio.run(run())
assert rc is not None
assert rc != 0
+79
View File
@@ -0,0 +1,79 @@
import asyncio
import logging
import httpx
import pytest
from takopi.logging import RedactTokenFilter
from takopi.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