feat(telegram): add per-chat/topic default agents (#109)

This commit is contained in:
banteg
2026-01-13 04:11:16 +04:00
committed by GitHub
parent 6ce08ee602
commit f060d3b59c
13 changed files with 660 additions and 31 deletions
+80
View File
@@ -1445,6 +1445,86 @@ 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_topic_default_engine(
tmp_path: Path,
) -> None:
state_path = tmp_path / "takopi.toml"
topic_path = resolve_state_path(state_path)
store = TopicStateStore(topic_path)
await store.set_session_resume(
123, 77, ResumeToken(engine=CODEX_ENGINE, value="resume-codex")
)
await store.set_session_resume(
123, 77, ResumeToken(engine=EngineId("claude"), value="resume-claude")
)
await store.set_default_engine(123, 77, "claude")
transport = _FakeTransport()
bot = _FakeBot()
codex_runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
claude_runner = ScriptRunner([Return(answer="ok")], engine=EngineId("claude"))
router = AutoRouter(
entries=[
RunnerEntry(engine=codex_runner.engine, runner=codex_runner),
RunnerEntry(engine=claude_runner.engine, runner=claude_runner),
],
default_engine=codex_runner.engine,
)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
chat_id=123,
)
},
default_project=None,
chat_map={123: "proj"},
)
runtime = TransportRuntime(
router=router,
projects=projects,
config_path=state_path,
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
),
topics=TelegramTopicsSettings(
enabled=True,
scope="main",
),
)
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,
thread_id=77,
)
await run_main_loop(cfg, poller)
assert codex_runner.calls == []
assert len(claude_runner.calls) == 1
assert claude_runner.calls[0][1] == ResumeToken(
engine=EngineId("claude"), value="resume-claude"
)
@pytest.mark.anyio
async def test_run_main_loop_auto_resumes_chat_sessions(tmp_path: Path) -> None:
resume_value = "resume-123"
+20
View File
@@ -0,0 +1,20 @@
import pytest
from takopi.telegram.chat_prefs import ChatPrefsStore
@pytest.mark.anyio
async def test_chat_prefs_store_roundtrip(tmp_path) -> None:
path = tmp_path / "telegram_chat_prefs_state.json"
store = ChatPrefsStore(path)
await store.set_default_engine(123, "codex")
await store.set_default_engine(123, "codex")
await store.clear_default_engine(456)
assert await store.get_default_engine(123) == "codex"
store2 = ChatPrefsStore(path)
assert await store2.get_default_engine(123) == "codex"
await store2.clear_default_engine(123)
assert await store2.get_default_engine(123) is None
+78
View File
@@ -0,0 +1,78 @@
from pathlib import Path
import pytest
from takopi.config import ProjectConfig, ProjectsConfig
from takopi.context import RunContext
from takopi.model import EngineId
from takopi.router import AutoRouter, RunnerEntry
from takopi.runners.mock import Return, ScriptRunner
from takopi.telegram.chat_prefs import ChatPrefsStore
from takopi.telegram.engine_defaults import resolve_engine_for_message
from takopi.telegram.topic_state import TopicStateStore
from takopi.transport_runtime import TransportRuntime
@pytest.mark.anyio
async def test_resolve_engine_for_message_sources(tmp_path) -> None:
codex = ScriptRunner([Return(answer="ok")], engine=EngineId("codex"))
pi = ScriptRunner([Return(answer="ok")], engine=EngineId("pi"))
router = AutoRouter(
entries=[
RunnerEntry(engine=codex.engine, runner=codex),
RunnerEntry(engine=pi.engine, runner=pi),
],
default_engine=codex.engine,
)
project = ProjectConfig(
alias="proj",
path=tmp_path,
worktrees_dir=Path(".worktrees"),
default_engine=pi.engine,
)
runtime = TransportRuntime(
router=router,
projects=ProjectsConfig(projects={"proj": project}, default_project=None),
)
chat_prefs = ChatPrefsStore(tmp_path / "telegram_chat_prefs_state.json")
topic_store = TopicStateStore(tmp_path / "telegram_topics_state.json")
await chat_prefs.set_default_engine(1, "pi")
await topic_store.set_default_engine(1, 10, "codex")
resolved = await resolve_engine_for_message(
runtime=runtime,
context=RunContext(project="proj"),
explicit_engine=EngineId("codex"),
chat_id=1,
topic_key=(1, 10),
topic_store=topic_store,
chat_prefs=chat_prefs,
)
assert resolved.source == "directive"
assert resolved.engine == "codex"
await topic_store.clear_default_engine(1, 10)
resolved = await resolve_engine_for_message(
runtime=runtime,
context=RunContext(project="proj"),
explicit_engine=None,
chat_id=1,
topic_key=(1, 10),
topic_store=topic_store,
chat_prefs=chat_prefs,
)
assert resolved.source == "chat_default"
assert resolved.engine == "pi"
await chat_prefs.clear_default_engine(1)
resolved = await resolve_engine_for_message(
runtime=runtime,
context=RunContext(project="proj"),
explicit_engine=None,
chat_id=1,
topic_key=(1, 10),
topic_store=topic_store,
chat_prefs=chat_prefs,
)
assert resolved.source == "project_default"
assert resolved.engine == "pi"
+7
View File
@@ -11,18 +11,21 @@ async def test_topic_state_store_roundtrip(tmp_path) -> None:
store = TopicStateStore(path)
context = RunContext(project="proj", branch="feat/topic")
await store.set_context(1, 10, context)
await store.set_default_engine(1, 10, "claude")
await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123"))
snapshot = await store.get_thread(1, 10)
assert snapshot is not None
assert snapshot.context == context
assert snapshot.sessions == {"codex": "abc123"}
assert snapshot.default_engine == "claude"
store2 = TopicStateStore(path)
snapshot2 = await store2.get_thread(1, 10)
assert snapshot2 is not None
assert snapshot2.context == context
assert snapshot2.sessions == {"codex": "abc123"}
assert snapshot2.default_engine == "claude"
@pytest.mark.anyio
@@ -47,3 +50,7 @@ async def test_topic_state_store_clear_and_find(tmp_path) -> None:
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.context is None
await store.clear_default_engine(2, 20)
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.default_engine is None