feat(telegram): add chat session mode (#102)

This commit is contained in:
banteg
2026-01-12 19:05:39 +04:00
committed by GitHub
parent 7825dd73a9
commit 637a9fc3e2
18 changed files with 622 additions and 30 deletions
+2 -2
View File
@@ -355,13 +355,13 @@ async def test_final_message_includes_ctx_line() -> None:
runner=runner,
incoming=IncomingMessage(channel_id=123, message_id=42, text="do it"),
resume_token=None,
context_line="`ctx: takopi @ feat/api`",
context_line="`ctx: takopi @feat/api`",
clock=clock,
)
assert transport.send_calls
final_text = transport.send_calls[-1]["message"].text
assert "`ctx: takopi @ feat/api`" in final_text
assert "`ctx: takopi @feat/api`" in final_text
assert "codex resume" in final_text.lower()
+2 -2
View File
@@ -187,12 +187,12 @@ def test_progress_renderer_footer_includes_ctx_before_resume() -> None:
state = tracker.snapshot(
resume_formatter=_format_resume,
context_line="`ctx: z80 @ feat/name`",
context_line="`ctx: z80 @feat/name`",
)
formatter = MarkdownFormatter(max_actions=5)
parts = formatter.render_progress_parts(state, elapsed_s=0.0)
assert parts.footer == (
"`ctx: z80 @ feat/name`"
"`ctx: z80 @feat/name`"
f"{HARD_BREAK}`codex resume 0199a213-81c0-7800-8aa1-bbab2a035a53`"
)
+243 -5
View File
@@ -34,6 +34,7 @@ from takopi.telegram.bridge import (
from takopi.telegram.client import BotClient
from takopi.telegram.render import MAX_BODY_CHARS
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.telegram.chat_sessions import ChatSessionStore, resolve_sessions_path
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
@@ -1040,7 +1041,7 @@ def test_resolve_message_accepts_backticked_ctx_line() -> None:
)
resolved = runtime.resolve_message(
text="do it",
reply_text="`ctx: takopi @ feat/api`",
reply_text="`ctx: takopi @feat/api`",
)
assert resolved.prompt == "do it"
@@ -1153,7 +1154,17 @@ async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> No
async def test_send_with_resume_waits_for_token() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
sent: list[
tuple[
int,
int,
str,
ResumeToken,
RunContext | None,
int | None,
tuple[int, int | None] | None,
]
] = []
async def enqueue(
chat_id: int,
@@ -1162,8 +1173,11 @@ async def test_send_with_resume_waits_for_token() -> None:
resume: ResumeToken,
context: RunContext | None,
thread_id: int | None,
session_key: tuple[int, int | None] | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
sent.append(
(chat_id, user_msg_id, text, resume, context, thread_id, session_key)
)
running_task = RunningTask()
@@ -1181,6 +1195,7 @@ async def test_send_with_resume_waits_for_token() -> None:
123,
10,
None,
None,
"hello",
)
@@ -1192,6 +1207,7 @@ async def test_send_with_resume_waits_for_token() -> None:
ResumeToken(engine=CODEX_ENGINE, value="abc123"),
None,
None,
None,
)
]
assert transport.send_calls == []
@@ -1201,7 +1217,17 @@ async def test_send_with_resume_waits_for_token() -> None:
async def test_send_with_resume_reports_when_missing() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
sent: list[
tuple[
int,
int,
str,
ResumeToken,
RunContext | None,
int | None,
tuple[int, int | None] | None,
]
] = []
async def enqueue(
chat_id: int,
@@ -1210,8 +1236,11 @@ async def test_send_with_resume_reports_when_missing() -> None:
resume: ResumeToken,
context: RunContext | None,
thread_id: int | None,
session_key: tuple[int, int | None] | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
sent.append(
(chat_id, user_msg_id, text, resume, context, thread_id, session_key)
)
running_task = RunningTask()
running_task.done.set()
@@ -1223,6 +1252,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
123,
10,
None,
None,
"hello",
)
@@ -1377,6 +1407,214 @@ async def test_run_main_loop_persists_topic_sessions_in_project_scope(
assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
@pytest.mark.anyio
async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None:
resume_value = "resume-123"
state_path = tmp_path / "takopi.toml"
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner(
[Return(answer="ok")],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=_empty_projects(),
config_path=state_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
session_mode="chat",
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="hello",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
chat_type="private",
)
await run_main_loop(cfg, poller)
store = ChatSessionStore(resolve_sessions_path(state_path))
stored = await store.get_session_resume(123, None, CODEX_ENGINE)
assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
runner2 = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
runtime2 = TransportRuntime(
router=_make_router(runner2),
projects=_empty_projects(),
config_path=state_path,
)
cfg2 = TelegramBridgeConfig(
bot=bot,
runtime=runtime2,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
session_mode="chat",
)
async def poller2(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=2,
text="followup",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
chat_type="private",
)
await run_main_loop(cfg2, poller2)
assert runner2.calls[0][1] == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
@pytest.mark.anyio
async def test_run_main_loop_chat_sessions_isolate_group_senders(
tmp_path: Path,
) -> None:
resume_value = "resume-group"
state_path = tmp_path / "takopi.toml"
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner(
[Return(answer="ok")],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=_empty_projects(),
config_path=state_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
session_mode="chat",
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=-100,
message_id=1,
text="hello",
reply_to_message_id=None,
reply_to_text=None,
sender_id=111,
chat_type="supergroup",
)
await run_main_loop(cfg, poller)
runner2 = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
runtime2 = TransportRuntime(
router=_make_router(runner2),
projects=_empty_projects(),
config_path=state_path,
)
cfg2 = TelegramBridgeConfig(
bot=bot,
runtime=runtime2,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
session_mode="chat",
)
async def poller2(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=-100,
message_id=2,
text="followup",
reply_to_message_id=None,
reply_to_text=None,
sender_id=222,
chat_type="supergroup",
)
await run_main_loop(cfg2, poller2)
assert runner2.calls[0][1] is None
@pytest.mark.anyio
async def test_run_main_loop_new_clears_chat_sessions(tmp_path: Path) -> None:
state_path = tmp_path / "takopi.toml"
store = ChatSessionStore(resolve_sessions_path(state_path))
await store.set_session_resume(
123, None, ResumeToken(engine=CODEX_ENGINE, value="resume-1")
)
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=_empty_projects(),
config_path=state_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
session_mode="chat",
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=123,
message_id=1,
text="/new",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
chat_type="private",
)
await run_main_loop(cfg, poller)
store2 = ChatSessionStore(resolve_sessions_path(state_path))
assert await store2.get_session_resume(123, None, CODEX_ENGINE) is None
@pytest.mark.anyio
async def test_run_main_loop_replies_in_same_thread() -> None:
transport = _FakeTransport()
+38
View File
@@ -0,0 +1,38 @@
import pytest
from takopi.model import ResumeToken
from takopi.telegram.chat_sessions import ChatSessionStore
@pytest.mark.anyio
async def test_chat_sessions_store_roundtrip(tmp_path) -> None:
path = tmp_path / "telegram_chat_sessions_state.json"
store = ChatSessionStore(path)
await store.set_session_resume(1, None, ResumeToken(engine="codex", value="abc123"))
await store.set_session_resume(1, 42, ResumeToken(engine="claude", value="res-1"))
stored_private = await store.get_session_resume(1, None, "codex")
stored_group = await store.get_session_resume(1, 42, "claude")
assert stored_private == ResumeToken(engine="codex", value="abc123")
assert stored_group == ResumeToken(engine="claude", value="res-1")
store2 = ChatSessionStore(path)
stored_private_2 = await store2.get_session_resume(1, None, "codex")
stored_group_2 = await store2.get_session_resume(1, 42, "claude")
assert stored_private_2 == ResumeToken(engine="codex", value="abc123")
assert stored_group_2 == ResumeToken(engine="claude", value="res-1")
@pytest.mark.anyio
async def test_chat_sessions_store_clear(tmp_path) -> None:
path = tmp_path / "telegram_chat_sessions_state.json"
store = ChatSessionStore(path)
await store.set_session_resume(2, None, ResumeToken(engine="codex", value="one"))
await store.set_session_resume(2, 77, ResumeToken(engine="codex", value="two"))
await store.clear_sessions(2, None)
assert await store.get_session_resume(2, None, "codex") is None
assert await store.get_session_resume(2, 77, "codex") == ResumeToken(
engine="codex",
value="two",
)
+1 -1
View File
@@ -93,7 +93,7 @@ def test_resolve_message_reply_ctx_overrides_ambient() -> None:
resolved = runtime.resolve_message(
text="hello",
reply_text="`ctx: proj @ reply`",
reply_text="`ctx: proj @reply`",
ambient_context=ambient,
)