feat: telegram forum topics support (#80)
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from takopi import cli
|
||||
from takopi.settings import TakopiSettings
|
||||
from takopi.telegram import onboarding
|
||||
|
||||
|
||||
def test_chat_id_command_updates_project_chat_id(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "takopi.toml"
|
||||
config_path.write_text(
|
||||
'[projects.z80]\npath = "/tmp/repo"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
|
||||
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
|
||||
|
||||
def _capture(*, token: str | None = None):
|
||||
assert token == "token"
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=123,
|
||||
username=None,
|
||||
title="takopi",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="supergroup",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli.onboarding, "capture_chat_id", _capture)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli.create_app(),
|
||||
["chat-id", "--token", "token", "--project", "z80"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
saved = config_path.read_text(encoding="utf-8")
|
||||
assert "chat_id = 123" in saved
|
||||
assert "updated projects.z80.chat_id = 123" in result.output
|
||||
|
||||
|
||||
def test_chat_id_command_uses_config_token(monkeypatch) -> None:
|
||||
settings = TakopiSettings.model_validate(
|
||||
{
|
||||
"transport": "telegram",
|
||||
"transports": {"telegram": {"bot_token": "config-token"}},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
|
||||
|
||||
def _capture(*, token: str | None = None):
|
||||
assert token == "config-token"
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=321,
|
||||
username=None,
|
||||
title="takopi",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="supergroup",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli.onboarding, "capture_chat_id", _capture)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.create_app(), ["chat-id"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "chat_id = 321" in result.output
|
||||
@@ -226,3 +226,51 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
|
||||
saved = config_path.read_text(encoding="utf-8")
|
||||
assert "[transports.telegram]" in saved
|
||||
assert 'bot_token = "123456789:ABCdef"' in saved
|
||||
|
||||
|
||||
def test_capture_chat_id_with_token(monkeypatch) -> None:
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._get_bot_info:
|
||||
return {"username": "my_bot"}
|
||||
if func is onboarding._wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=456,
|
||||
username=None,
|
||||
title="takopi",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
chat_type="supergroup",
|
||||
)
|
||||
raise AssertionError(f"unexpected anyio.run target: {func}")
|
||||
|
||||
monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
|
||||
|
||||
chat = onboarding.capture_chat_id(token="123456789:ABCdef")
|
||||
|
||||
assert chat is not None
|
||||
assert chat.chat_id == 456
|
||||
|
||||
|
||||
def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
onboarding, "_prompt_token", lambda _console: ("token", {"username": "bot"})
|
||||
)
|
||||
|
||||
def _fake_run(func, *args, **kwargs):
|
||||
if func is onboarding._wait_for_chat:
|
||||
return onboarding.ChatInfo(
|
||||
chat_id=789,
|
||||
username="alice",
|
||||
title=None,
|
||||
first_name="Alice",
|
||||
last_name=None,
|
||||
chat_type="private",
|
||||
)
|
||||
raise AssertionError(f"unexpected anyio.run target: {func}")
|
||||
|
||||
monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
|
||||
|
||||
chat = onboarding.capture_chat_id()
|
||||
|
||||
assert chat is not None
|
||||
assert chat.chat_id == 789
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import takopi.runner as runner_module
|
||||
from takopi.model import (
|
||||
ActionEvent,
|
||||
CompletedEvent,
|
||||
EngineId,
|
||||
ResumeToken,
|
||||
StartedEvent,
|
||||
TakopiEvent,
|
||||
)
|
||||
from takopi.runner import (
|
||||
BaseRunner,
|
||||
JsonlRunState,
|
||||
JsonlSubprocessRunner,
|
||||
ResumeTokenMixin,
|
||||
)
|
||||
|
||||
|
||||
class _DummyRunner(ResumeTokenMixin, BaseRunner):
|
||||
engine = EngineId("dummy")
|
||||
resume_re = re.compile(r"(?im)^`?dummy resume (?P<token>[^`\s]+)`?$")
|
||||
|
||||
async def run_impl(
|
||||
self, prompt: str, resume: ResumeToken | None
|
||||
) -> AsyncIterator[StartedEvent | CompletedEvent]:
|
||||
token = resume or ResumeToken(engine=self.engine, value="token")
|
||||
yield StartedEvent(engine=self.engine, resume=token, title="dummy")
|
||||
yield CompletedEvent(
|
||||
engine=self.engine,
|
||||
ok=True,
|
||||
answer=prompt,
|
||||
resume=token,
|
||||
)
|
||||
|
||||
|
||||
class _DummyJsonlRunner(JsonlSubprocessRunner):
|
||||
engine = EngineId("dummy-jsonl")
|
||||
|
||||
def command(self) -> str:
|
||||
return "dummy"
|
||||
|
||||
def build_args(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: object,
|
||||
) -> list[str]:
|
||||
_ = prompt, resume, state
|
||||
return []
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: Any,
|
||||
*,
|
||||
state: Any,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = data, state, resume, found_session
|
||||
return []
|
||||
|
||||
|
||||
class _BareJsonlRunner(JsonlSubprocessRunner):
|
||||
engine = EngineId("bare-jsonl")
|
||||
|
||||
|
||||
class _RunJsonlRunner(_DummyJsonlRunner):
|
||||
def stdin_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: Any,
|
||||
) -> bytes | None:
|
||||
_ = prompt, resume, state
|
||||
return None
|
||||
|
||||
async def iter_json_lines(self, stream: Any) -> AsyncIterator[bytes]:
|
||||
_ = stream
|
||||
yield b'{"type": "started", "resume": "sid"}'
|
||||
yield b'{"type": "completed", "resume": "sid"}'
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: Any,
|
||||
*,
|
||||
state: Any,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = state, resume, found_session
|
||||
token_value = "sid"
|
||||
if isinstance(data, dict) and isinstance(data.get("resume"), str):
|
||||
token_value = data["resume"]
|
||||
token = ResumeToken(engine=self.engine, value=token_value)
|
||||
if isinstance(data, dict) and data.get("type") == "started":
|
||||
return [StartedEvent(engine=self.engine, resume=token, title="t")]
|
||||
if isinstance(data, dict) and data.get("type") == "completed":
|
||||
return [
|
||||
CompletedEvent(engine=self.engine, ok=True, answer="done", resume=token)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
class _BranchingJsonlRunner(_DummyJsonlRunner):
|
||||
def stdin_payload(
|
||||
self,
|
||||
prompt: str,
|
||||
resume: ResumeToken | None,
|
||||
*,
|
||||
state: Any,
|
||||
) -> bytes | None:
|
||||
_ = prompt, resume, state
|
||||
return None
|
||||
|
||||
async def iter_json_lines(self, stream: Any) -> AsyncIterator[bytes]:
|
||||
_ = stream
|
||||
yield b"raise"
|
||||
yield b""
|
||||
yield b"invalid"
|
||||
yield b'{"type": "translate_error"}'
|
||||
yield b'{"type": "started", "resume": "sid"}'
|
||||
yield b'{"type": "started", "resume": "sid"}'
|
||||
yield b'{"type": "completed", "resume": "sid"}'
|
||||
yield b'{"type": "after"}'
|
||||
|
||||
def decode_jsonl(self, *, line: bytes) -> Any | None:
|
||||
if line == b"raise":
|
||||
raise ValueError("boom")
|
||||
if line == b"invalid":
|
||||
return None
|
||||
return super().decode_jsonl(line=line)
|
||||
|
||||
def translate(
|
||||
self,
|
||||
data: Any,
|
||||
*,
|
||||
state: Any,
|
||||
resume: ResumeToken | None,
|
||||
found_session: ResumeToken | None,
|
||||
) -> list[TakopiEvent]:
|
||||
_ = state, resume, found_session
|
||||
if isinstance(data, dict) and data.get("type") == "translate_error":
|
||||
raise RuntimeError("nope")
|
||||
token_value = "sid"
|
||||
if isinstance(data, dict) and isinstance(data.get("resume"), str):
|
||||
token_value = data["resume"]
|
||||
token = ResumeToken(engine=self.engine, value=token_value)
|
||||
if isinstance(data, dict) and data.get("type") == "started":
|
||||
return [StartedEvent(engine=self.engine, resume=token, title="t")]
|
||||
if isinstance(data, dict) and data.get("type") == "completed":
|
||||
return [
|
||||
CompletedEvent(engine=self.engine, ok=True, answer="done", resume=token)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_runner_run_locked_handles_resume() -> None:
|
||||
runner = _DummyRunner()
|
||||
events = [evt async for evt in runner.run("hello", None)]
|
||||
assert isinstance(events[0], StartedEvent)
|
||||
assert isinstance(events[-1], CompletedEvent)
|
||||
|
||||
resume = ResumeToken(engine=runner.engine, value="resume")
|
||||
resumed = [evt async for evt in runner.run("again", resume)]
|
||||
assert isinstance(resumed[0], StartedEvent)
|
||||
assert resumed[0].resume == resume
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_runner_rejects_wrong_resume_engine() -> None:
|
||||
runner = _DummyRunner()
|
||||
bad_resume = ResumeToken(engine=EngineId("other"), value="oops")
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = [evt async for evt in runner.run("hello", bad_resume)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_base_runner_run_impl_not_implemented() -> None:
|
||||
class _BareRunner(BaseRunner):
|
||||
engine = EngineId("bare")
|
||||
|
||||
runner = _BareRunner()
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = [evt async for evt in runner.run_impl("hello", None)]
|
||||
|
||||
|
||||
def test_resume_token_format_and_extract() -> None:
|
||||
runner = _DummyRunner()
|
||||
token = ResumeToken(engine=runner.engine, value="abc")
|
||||
assert runner.format_resume(token) == "`dummy resume abc`"
|
||||
assert runner.is_resume_line("`dummy resume abc`") is True
|
||||
text = "`dummy resume first`\n`dummy resume second`"
|
||||
assert runner.extract_resume(text) == ResumeToken(
|
||||
engine=runner.engine, value="second"
|
||||
)
|
||||
assert runner.extract_resume(None) is None
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
runner.format_resume(ResumeToken(engine=EngineId("other"), value="bad"))
|
||||
|
||||
|
||||
def test_session_lock_reuse() -> None:
|
||||
runner = _DummyRunner()
|
||||
token = ResumeToken(engine=runner.engine, value="one")
|
||||
lock1 = runner.lock_for(token)
|
||||
lock2 = runner.lock_for(token)
|
||||
other = runner.lock_for(ResumeToken(engine=runner.engine, value="two"))
|
||||
assert lock1 is lock2
|
||||
assert other is not lock1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_with_resume_lock_passthrough() -> None:
|
||||
runner = _DummyRunner()
|
||||
events = [
|
||||
evt async for evt in runner.run_with_resume_lock("hello", None, runner.run_impl)
|
||||
]
|
||||
assert events
|
||||
|
||||
|
||||
def test_jsonl_helpers() -> None:
|
||||
runner = _DummyJsonlRunner()
|
||||
state = JsonlRunState()
|
||||
|
||||
note1 = runner.next_note_id(state)
|
||||
note2 = runner.next_note_id(state)
|
||||
assert note1.endswith(".1")
|
||||
assert note2.endswith(".2")
|
||||
|
||||
event = runner.note_event("warn", state=state)
|
||||
assert isinstance(event, ActionEvent)
|
||||
assert event.action.detail == {}
|
||||
|
||||
invalid = runner.invalid_json_events(raw="x", line="{}", state=state)
|
||||
invalid_event = invalid[0]
|
||||
assert isinstance(invalid_event, ActionEvent)
|
||||
assert invalid_event.action.detail["line"] == "{}"
|
||||
|
||||
assert runner.decode_jsonl(line=b'{"a": 1}') == {"a": 1}
|
||||
assert runner.decode_jsonl(line=b"{") is None
|
||||
|
||||
err_events = runner.decode_error_events(
|
||||
raw="oops", line="{}", error=ValueError("nope"), state=state
|
||||
)
|
||||
err_event = err_events[0]
|
||||
assert isinstance(err_event, ActionEvent)
|
||||
assert err_event.action.detail["error"] == "nope"
|
||||
|
||||
translated = runner.translate_error_events(
|
||||
data={"type": "foo", "item": {"type": "bar"}},
|
||||
error=ValueError("boom"),
|
||||
state=state,
|
||||
)
|
||||
translated_event = translated[0]
|
||||
assert isinstance(translated_event, ActionEvent)
|
||||
detail = translated_event.action.detail
|
||||
assert detail["type"] == "foo"
|
||||
assert detail["item_type"] == "bar"
|
||||
|
||||
resume = ResumeToken(engine=runner.engine, value="sid")
|
||||
processed = runner.process_error_events(
|
||||
2, resume=resume, found_session=None, state=state
|
||||
)
|
||||
processed_event = processed[-1]
|
||||
assert isinstance(processed_event, CompletedEvent)
|
||||
assert processed_event.ok is False
|
||||
assert processed_event.resume == resume
|
||||
|
||||
stream_end = runner.stream_end_events(
|
||||
resume=None, found_session=resume, state=state
|
||||
)
|
||||
stream_event = stream_end[-1]
|
||||
assert isinstance(stream_event, CompletedEvent)
|
||||
assert stream_event.resume == resume
|
||||
|
||||
started = StartedEvent(engine=runner.engine, resume=resume, title="t")
|
||||
found, emit = runner.handle_started_event(
|
||||
started, expected_session=None, found_session=None
|
||||
)
|
||||
assert found == resume
|
||||
assert emit is True
|
||||
|
||||
found, emit = runner.handle_started_event(
|
||||
started, expected_session=None, found_session=resume
|
||||
)
|
||||
assert found == resume
|
||||
assert emit is False
|
||||
|
||||
mismatch = StartedEvent(engine=EngineId("other"), resume=resume, title="t")
|
||||
with pytest.raises(RuntimeError):
|
||||
runner.handle_started_event(mismatch, expected_session=None, found_session=None)
|
||||
|
||||
other_resume = ResumeToken(engine=runner.engine, value="other")
|
||||
with pytest.raises(RuntimeError):
|
||||
runner.handle_started_event(
|
||||
StartedEvent(engine=runner.engine, resume=other_resume, title="t"),
|
||||
expected_session=resume,
|
||||
found_session=None,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
runner.handle_started_event(
|
||||
StartedEvent(engine=runner.engine, resume=other_resume, title="t"),
|
||||
expected_session=None,
|
||||
found_session=resume,
|
||||
)
|
||||
|
||||
|
||||
def test_next_note_id_requires_state_field() -> None:
|
||||
runner = _DummyJsonlRunner()
|
||||
with pytest.raises(RuntimeError):
|
||||
runner.next_note_id(object())
|
||||
|
||||
|
||||
def test_jsonl_base_methods_raise_and_defaults() -> None:
|
||||
runner = _BareJsonlRunner()
|
||||
with pytest.raises(NotImplementedError):
|
||||
runner.command()
|
||||
with pytest.raises(NotImplementedError):
|
||||
runner.build_args("hi", None, state=None)
|
||||
with pytest.raises(NotImplementedError):
|
||||
runner.translate(data={}, state=None, resume=None, found_session=None)
|
||||
assert runner.pipes_error_message().startswith("bare-jsonl")
|
||||
state = runner.new_state("hi", None)
|
||||
assert isinstance(state, JsonlRunState)
|
||||
assert runner.start_run("hi", None, state=state) is None
|
||||
assert runner.env(state=state) is None
|
||||
assert runner.stdin_payload("hi", None, state=state) == b"hi"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_jsonl_run_impl_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _FakeProc:
|
||||
def __init__(self) -> None:
|
||||
self.stdout = object()
|
||||
self.stderr = object()
|
||||
self.stdin = None
|
||||
self.pid = 123
|
||||
|
||||
async def wait(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeManager:
|
||||
def __init__(self, proc: _FakeProc) -> None:
|
||||
self._proc = proc
|
||||
|
||||
async def __aenter__(self) -> _FakeProc:
|
||||
return self._proc
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
proc = _FakeProc()
|
||||
|
||||
def fake_manage_subprocess(*args: Any, **kwargs: Any) -> _FakeManager:
|
||||
_ = args, kwargs
|
||||
return _FakeManager(proc)
|
||||
|
||||
async def fake_drain_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
_ = args, kwargs
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(runner_module, "manage_subprocess", fake_manage_subprocess)
|
||||
monkeypatch.setattr(runner_module, "drain_stderr", fake_drain_stderr)
|
||||
|
||||
runner = _RunJsonlRunner()
|
||||
events = [evt async for evt in runner.run_impl("hello", None)]
|
||||
assert any(isinstance(evt, CompletedEvent) for evt in events)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_jsonl_run_impl_branches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class _FakeProc:
|
||||
def __init__(self) -> None:
|
||||
self.stdout = object()
|
||||
self.stderr = object()
|
||||
self.stdin = None
|
||||
self.pid = 456
|
||||
|
||||
async def wait(self) -> int:
|
||||
return 0
|
||||
|
||||
class _FakeManager:
|
||||
def __init__(self, proc: _FakeProc) -> None:
|
||||
self._proc = proc
|
||||
|
||||
async def __aenter__(self) -> _FakeProc:
|
||||
return self._proc
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
proc = _FakeProc()
|
||||
|
||||
def fake_manage_subprocess(*args: Any, **kwargs: Any) -> _FakeManager:
|
||||
_ = args, kwargs
|
||||
return _FakeManager(proc)
|
||||
|
||||
async def fake_drain_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
_ = args, kwargs
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(runner_module, "manage_subprocess", fake_manage_subprocess)
|
||||
monkeypatch.setattr(runner_module, "drain_stderr", fake_drain_stderr)
|
||||
|
||||
runner = _BranchingJsonlRunner()
|
||||
events = [evt async for evt in runner.run_impl("hello", None)]
|
||||
assert any(isinstance(evt, CompletedEvent) for evt in events)
|
||||
+246
-25
@@ -1,5 +1,6 @@
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
@@ -18,6 +19,8 @@ from takopi.telegram.bridge import (
|
||||
_send_with_resume,
|
||||
run_main_loop,
|
||||
)
|
||||
from takopi.telegram.client import BotClient
|
||||
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
|
||||
from takopi.context import RunContext
|
||||
from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config
|
||||
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
|
||||
@@ -92,12 +95,13 @@ class _FakeTransport:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
class _FakeBot(BotClient):
|
||||
def __init__(self) -> None:
|
||||
self.command_calls: list[dict] = []
|
||||
self.callback_calls: list[dict] = []
|
||||
self.send_calls: list[dict] = []
|
||||
self.edit_calls: list[dict] = []
|
||||
self.edit_topic_calls: list[dict[str, Any]] = []
|
||||
self.delete_calls: list[dict] = []
|
||||
|
||||
async def get_updates(
|
||||
@@ -105,13 +109,13 @@ class _FakeBot:
|
||||
offset: int | None,
|
||||
timeout_s: int = 50,
|
||||
allowed_updates: list[str] | None = None,
|
||||
) -> list[dict] | None:
|
||||
) -> list[dict[str, Any]] | None:
|
||||
_ = offset
|
||||
_ = timeout_s
|
||||
_ = allowed_updates
|
||||
return []
|
||||
|
||||
async def get_file(self, file_id: str) -> dict | None:
|
||||
async def get_file(self, file_id: str) -> dict[str, Any] | None:
|
||||
_ = file_id
|
||||
return None
|
||||
|
||||
@@ -125,18 +129,20 @@ class _FakeBot:
|
||||
text: str,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
message_thread_id: int | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
replace_message_id: int | None = None,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
self.send_calls.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"reply_to_message_id": reply_to_message_id,
|
||||
"disable_notification": disable_notification,
|
||||
"message_thread_id": message_thread_id,
|
||||
"entities": entities,
|
||||
"parse_mode": parse_mode,
|
||||
"reply_markup": reply_markup,
|
||||
@@ -150,12 +156,12 @@ class _FakeBot:
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
self.edit_calls.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
@@ -175,9 +181,9 @@ class _FakeBot:
|
||||
|
||||
async def set_my_commands(
|
||||
self,
|
||||
commands: list[dict],
|
||||
commands: list[dict[str, Any]],
|
||||
*,
|
||||
scope: dict | None = None,
|
||||
scope: dict[str, Any] | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> bool:
|
||||
self.command_calls.append(
|
||||
@@ -189,9 +195,39 @@ class _FakeBot:
|
||||
)
|
||||
return True
|
||||
|
||||
async def get_me(self) -> dict | None:
|
||||
async def get_me(self) -> dict[str, Any] | None:
|
||||
return {"id": 1}
|
||||
|
||||
async def get_chat(self, chat_id: int) -> dict[str, Any] | None:
|
||||
_ = chat_id
|
||||
return {"id": chat_id, "type": "supergroup", "is_forum": True}
|
||||
|
||||
async def get_chat_member(
|
||||
self, chat_id: int, user_id: int
|
||||
) -> dict[str, Any] | None:
|
||||
_ = chat_id
|
||||
_ = user_id
|
||||
return {"status": "administrator", "can_manage_topics": True}
|
||||
|
||||
async def create_forum_topic(
|
||||
self, chat_id: int, name: str
|
||||
) -> dict[str, Any] | None:
|
||||
_ = chat_id
|
||||
_ = name
|
||||
return {"message_thread_id": 1}
|
||||
|
||||
async def edit_forum_topic(
|
||||
self, chat_id: int, message_thread_id: int, name: str
|
||||
) -> bool:
|
||||
self.edit_topic_calls.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"message_thread_id": message_thread_id,
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -457,19 +493,19 @@ async def test_telegram_transport_passes_reply_markup() -> None:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
class _OutboxBot:
|
||||
class _OutboxBot(BotClient):
|
||||
def __init__(self) -> None:
|
||||
self.edit_calls: list[dict[str, object]] = []
|
||||
self.edit_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def get_updates(
|
||||
self,
|
||||
offset: int | None,
|
||||
timeout_s: int = 50,
|
||||
allowed_updates: list[str] | None = None,
|
||||
) -> list[dict] | None:
|
||||
) -> list[dict[str, Any]] | None:
|
||||
return None
|
||||
|
||||
async def get_file(self, file_id: str) -> dict | None:
|
||||
async def get_file(self, file_id: str) -> dict[str, Any] | None:
|
||||
_ = file_id
|
||||
return None
|
||||
|
||||
@@ -483,7 +519,8 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
text: str,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
message_thread_id: int | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
@@ -497,7 +534,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
@@ -527,14 +564,14 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
|
||||
|
||||
async def set_my_commands(
|
||||
self,
|
||||
commands: list[dict[str, object]],
|
||||
commands: list[dict[str, Any]],
|
||||
*,
|
||||
scope: dict[str, object] | None = None,
|
||||
scope: dict[str, Any] | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def get_me(self) -> dict | None:
|
||||
async def get_me(self) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
@@ -755,11 +792,115 @@ def test_resolve_message_accepts_backticked_ctx_line() -> None:
|
||||
assert resolved.context == RunContext(project="takopi", branch="feat/api")
|
||||
|
||||
|
||||
def test_topic_title_matches_command_syntax() -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch="master"),
|
||||
)
|
||||
|
||||
assert title == "takopi @master"
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch=None),
|
||||
)
|
||||
|
||||
assert title == "takopi"
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project=None, branch="main"),
|
||||
)
|
||||
|
||||
assert title == "@main"
|
||||
|
||||
|
||||
def test_topic_title_per_project_chat_includes_project() -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = replace(
|
||||
_make_cfg(transport),
|
||||
topics=bridge.TelegramTopicsConfig(
|
||||
enabled=True,
|
||||
mode="per_project_chat",
|
||||
),
|
||||
)
|
||||
|
||||
title = bridge._topic_title(
|
||||
cfg=cfg,
|
||||
runtime=cfg.runtime,
|
||||
context=RunContext(project="takopi", branch="master"),
|
||||
)
|
||||
|
||||
assert title == "takopi @master"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
store = TopicStateStore(tmp_path / "telegram_topics_state.json")
|
||||
|
||||
await store.set_context(
|
||||
123,
|
||||
77,
|
||||
RunContext(project="takopi", branch="old"),
|
||||
topic_title="takopi @old",
|
||||
)
|
||||
|
||||
await bridge._maybe_rename_topic(
|
||||
cfg,
|
||||
store,
|
||||
chat_id=123,
|
||||
thread_id=77,
|
||||
context=RunContext(project="takopi", branch="new"),
|
||||
)
|
||||
|
||||
bot = cast(_FakeBot, cfg.bot)
|
||||
assert bot.edit_topic_calls
|
||||
assert bot.edit_topic_calls[-1]["name"] == "takopi @new"
|
||||
snapshot = await store.get_thread(123, 77)
|
||||
assert snapshot is not None
|
||||
assert snapshot.topic_title == "takopi @new"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> None:
|
||||
transport = _FakeTransport()
|
||||
cfg = _make_cfg(transport)
|
||||
store = TopicStateStore(tmp_path / "telegram_topics_state.json")
|
||||
|
||||
await store.set_context(
|
||||
123,
|
||||
77,
|
||||
RunContext(project="takopi", branch="main"),
|
||||
topic_title="takopi @main",
|
||||
)
|
||||
snapshot = await store.get_thread(123, 77)
|
||||
|
||||
await bridge._maybe_rename_topic(
|
||||
cfg,
|
||||
store,
|
||||
chat_id=123,
|
||||
thread_id=77,
|
||||
context=RunContext(project="takopi", branch="main"),
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
bot = cast(_FakeBot, cfg.bot)
|
||||
assert bot.edit_topic_calls == []
|
||||
|
||||
|
||||
@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, RunContext | None]] = []
|
||||
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
|
||||
|
||||
async def enqueue(
|
||||
chat_id: int,
|
||||
@@ -767,8 +908,9 @@ async def test_send_with_resume_waits_for_token() -> None:
|
||||
text: str,
|
||||
resume: ResumeToken,
|
||||
context: RunContext | None,
|
||||
thread_id: int | None,
|
||||
) -> None:
|
||||
sent.append((chat_id, user_msg_id, text, resume, context))
|
||||
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
|
||||
|
||||
running_task = RunningTask()
|
||||
|
||||
@@ -785,11 +927,19 @@ async def test_send_with_resume_waits_for_token() -> None:
|
||||
running_task,
|
||||
123,
|
||||
10,
|
||||
None,
|
||||
"hello",
|
||||
)
|
||||
|
||||
assert sent == [
|
||||
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"), None)
|
||||
(
|
||||
123,
|
||||
10,
|
||||
"hello",
|
||||
ResumeToken(engine=CODEX_ENGINE, value="abc123"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
]
|
||||
assert transport.send_calls == []
|
||||
|
||||
@@ -798,7 +948,7 @@ 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]] = []
|
||||
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
|
||||
|
||||
async def enqueue(
|
||||
chat_id: int,
|
||||
@@ -806,8 +956,9 @@ async def test_send_with_resume_reports_when_missing() -> None:
|
||||
text: str,
|
||||
resume: ResumeToken,
|
||||
context: RunContext | None,
|
||||
thread_id: int | None,
|
||||
) -> None:
|
||||
sent.append((chat_id, user_msg_id, text, resume, context))
|
||||
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
|
||||
|
||||
running_task = RunningTask()
|
||||
running_task.done.set()
|
||||
@@ -818,6 +969,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
|
||||
running_task,
|
||||
123,
|
||||
10,
|
||||
None,
|
||||
"hello",
|
||||
)
|
||||
|
||||
@@ -903,6 +1055,75 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_main_loop_persists_topic_sessions_in_per_project_chat(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
project_chat_id = -100
|
||||
resume_value = "resume-123"
|
||||
|
||||
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,
|
||||
)
|
||||
projects = ProjectsConfig(
|
||||
projects={
|
||||
"takopi": ProjectConfig(
|
||||
alias="takopi",
|
||||
path=Path("."),
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
chat_id=project_chat_id,
|
||||
)
|
||||
},
|
||||
default_project=None,
|
||||
chat_map={project_chat_id: "takopi"},
|
||||
)
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=projects,
|
||||
config_path=tmp_path / "takopi.toml",
|
||||
)
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
runtime=runtime,
|
||||
chat_id=123,
|
||||
startup_msg="",
|
||||
exec_cfg=exec_cfg,
|
||||
topics=bridge.TelegramTopicsConfig(
|
||||
enabled=True,
|
||||
mode="per_project_chat",
|
||||
),
|
||||
)
|
||||
|
||||
async def poller(_cfg: TelegramBridgeConfig):
|
||||
yield TelegramIncomingMessage(
|
||||
transport="telegram",
|
||||
chat_id=project_chat_id,
|
||||
message_id=1,
|
||||
text="hello",
|
||||
reply_to_message_id=None,
|
||||
reply_to_text=None,
|
||||
sender_id=123,
|
||||
thread_id=77,
|
||||
)
|
||||
|
||||
with anyio.fail_after(2):
|
||||
await run_main_loop(cfg, poller)
|
||||
|
||||
state_path = resolve_state_path(runtime.config_path or tmp_path / "takopi.toml")
|
||||
store = TopicStateStore(state_path)
|
||||
stored = await store.get_session_resume(project_chat_id, 77, CODEX_ENGINE)
|
||||
assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
|
||||
class _Command:
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_parse_incoming_update_maps_fields() -> None:
|
||||
"message": {
|
||||
"message_id": 10,
|
||||
"text": "hello",
|
||||
"chat": {"id": 123},
|
||||
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
|
||||
"from": {"id": 99},
|
||||
"reply_to_message": {"message_id": 5, "text": "prev"},
|
||||
},
|
||||
@@ -27,6 +27,10 @@ def test_parse_incoming_update_maps_fields() -> None:
|
||||
assert msg.reply_to_message_id == 5
|
||||
assert msg.reply_to_text == "prev"
|
||||
assert msg.sender_id == 99
|
||||
assert msg.thread_id is None
|
||||
assert msg.is_topic_message is None
|
||||
assert msg.chat_type == "supergroup"
|
||||
assert msg.is_forum is True
|
||||
assert msg.voice is None
|
||||
assert msg.raw == update["message"]
|
||||
|
||||
@@ -102,3 +106,23 @@ def test_parse_incoming_update_callback_query() -> None:
|
||||
assert msg.callback_query_id == "cbq-1"
|
||||
assert msg.data == "takopi:cancel"
|
||||
assert msg.sender_id == 321
|
||||
|
||||
|
||||
def test_parse_incoming_update_topic_fields() -> None:
|
||||
update = {
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"message_id": 10,
|
||||
"text": "hello",
|
||||
"message_thread_id": 77,
|
||||
"is_topic_message": True,
|
||||
"chat": {"id": -100, "type": "supergroup", "is_forum": True},
|
||||
},
|
||||
}
|
||||
|
||||
msg = parse_incoming_update(update, chat_id=-100)
|
||||
assert isinstance(msg, TelegramIncomingMessage)
|
||||
assert msg.thread_id == 77
|
||||
assert msg.is_topic_message is True
|
||||
assert msg.chat_type == "supergroup"
|
||||
assert msg.is_forum is True
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from takopi.telegram.client import TelegramClient, TelegramRetryAfter
|
||||
from takopi.telegram.client import BotClient, TelegramClient, TelegramRetryAfter
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
class _FakeBot(BotClient):
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.edit_calls: list[str] = []
|
||||
self.delete_calls: list[tuple[int, int]] = []
|
||||
self.topic_calls: list[tuple[int, int, str]] = []
|
||||
self._edit_attempts = 0
|
||||
self._updates_attempts = 0
|
||||
self.retry_after: float | None = None
|
||||
@@ -20,14 +23,16 @@ class _FakeBot:
|
||||
text: str,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_notification: bool | None = False,
|
||||
entities: list[dict] | None = None,
|
||||
message_thread_id: int | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
replace_message_id: int | None = None,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
_ = reply_to_message_id
|
||||
_ = disable_notification
|
||||
_ = message_thread_id
|
||||
_ = entities
|
||||
_ = parse_mode
|
||||
_ = reply_markup
|
||||
@@ -40,12 +45,12 @@ class _FakeBot:
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
text: str,
|
||||
entities: list[dict] | None = None,
|
||||
entities: list[dict[str, Any]] | None = None,
|
||||
parse_mode: str | None = None,
|
||||
reply_markup: dict | None = None,
|
||||
*,
|
||||
wait: bool = True,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
_ = chat_id
|
||||
_ = message_id
|
||||
_ = entities
|
||||
@@ -71,9 +76,9 @@ class _FakeBot:
|
||||
|
||||
async def set_my_commands(
|
||||
self,
|
||||
commands: list[dict],
|
||||
commands: list[dict[str, Any]],
|
||||
*,
|
||||
scope: dict | None = None,
|
||||
scope: dict[str, Any] | None = None,
|
||||
language_code: str | None = None,
|
||||
) -> bool:
|
||||
_ = commands
|
||||
@@ -86,7 +91,7 @@ class _FakeBot:
|
||||
offset: int | None,
|
||||
timeout_s: int = 50,
|
||||
allowed_updates: list[str] | None = None,
|
||||
) -> list[dict] | None:
|
||||
) -> list[dict[str, Any]] | None:
|
||||
_ = offset
|
||||
_ = timeout_s
|
||||
_ = allowed_updates
|
||||
@@ -96,7 +101,7 @@ class _FakeBot:
|
||||
self._updates_attempts += 1
|
||||
return []
|
||||
|
||||
async def get_file(self, file_id: str) -> dict | None:
|
||||
async def get_file(self, file_id: str) -> dict[str, Any] | None:
|
||||
_ = file_id
|
||||
return None
|
||||
|
||||
@@ -107,7 +112,7 @@ class _FakeBot:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def get_me(self) -> dict | None:
|
||||
async def get_me(self) -> dict[str, Any] | None:
|
||||
return {"id": 1}
|
||||
|
||||
async def answer_callback_query(
|
||||
@@ -119,6 +124,27 @@ class _FakeBot:
|
||||
_ = callback_query_id, text, show_alert
|
||||
return True
|
||||
|
||||
async def edit_forum_topic(
|
||||
self, chat_id: int, message_thread_id: int, name: str
|
||||
) -> bool:
|
||||
self.calls.append("edit_forum_topic")
|
||||
self.topic_calls.append((chat_id, message_thread_id, name))
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edit_forum_topic_uses_outbox() -> None:
|
||||
bot = _FakeBot()
|
||||
client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0)
|
||||
|
||||
result = await client.edit_forum_topic(
|
||||
chat_id=7, message_thread_id=42, name="takopi @main"
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert bot.calls == ["edit_forum_topic"]
|
||||
assert bot.topic_calls == [(7, 42, "takopi @main")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edits_coalesce_latest() -> None:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from takopi.context import RunContext
|
||||
from takopi.model import ResumeToken
|
||||
from takopi.telegram.topic_state import TopicStateStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_topic_state_store_roundtrip(tmp_path) -> None:
|
||||
path = tmp_path / "telegram_topics_state.json"
|
||||
store = TopicStateStore(path)
|
||||
context = RunContext(project="proj", branch="feat/topic")
|
||||
await store.set_context(1, 10, context)
|
||||
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"}
|
||||
|
||||
store2 = TopicStateStore(path)
|
||||
snapshot2 = await store2.get_thread(1, 10)
|
||||
assert snapshot2 is not None
|
||||
assert snapshot2.context == context
|
||||
assert snapshot2.sessions == {"codex": "abc123"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_topic_state_store_clear_and_find(tmp_path) -> None:
|
||||
path = tmp_path / "telegram_topics_state.json"
|
||||
store = TopicStateStore(path)
|
||||
context = RunContext(project="proj", branch="main")
|
||||
await store.set_context(2, 20, context)
|
||||
await store.set_session_resume(
|
||||
2, 20, ResumeToken(engine="claude", value="resume-token")
|
||||
)
|
||||
|
||||
found = await store.find_thread_for_context(2, context)
|
||||
assert found == 20
|
||||
|
||||
await store.clear_sessions(2, 20)
|
||||
snapshot = await store.get_thread(2, 20)
|
||||
assert snapshot is not None
|
||||
assert snapshot.sessions == {}
|
||||
|
||||
await store.clear_context(2, 20)
|
||||
snapshot = await store.get_thread(2, 20)
|
||||
assert snapshot is not None
|
||||
assert snapshot.context is None
|
||||
@@ -71,3 +71,93 @@ def test_resolve_message_defaults_to_chat_project() -> None:
|
||||
)
|
||||
|
||||
assert resolved.context == RunContext(project="proj", branch=None)
|
||||
|
||||
|
||||
def test_resolve_message_uses_ambient_context() -> None:
|
||||
runtime = _make_runtime()
|
||||
ambient = RunContext(project="proj", branch="feat/ambient")
|
||||
|
||||
resolved = runtime.resolve_message(
|
||||
text="hello",
|
||||
reply_text=None,
|
||||
ambient_context=ambient,
|
||||
)
|
||||
|
||||
assert resolved.context == ambient
|
||||
assert resolved.context_source == "ambient"
|
||||
|
||||
|
||||
def test_resolve_message_reply_ctx_overrides_ambient() -> None:
|
||||
runtime = _make_runtime()
|
||||
ambient = RunContext(project="proj", branch="feat/ambient")
|
||||
|
||||
resolved = runtime.resolve_message(
|
||||
text="hello",
|
||||
reply_text="`ctx: proj @ reply`",
|
||||
ambient_context=ambient,
|
||||
)
|
||||
|
||||
assert resolved.context == RunContext(project="proj", branch="reply")
|
||||
assert resolved.context_source == "reply_ctx"
|
||||
|
||||
|
||||
def test_resolve_message_directives_override_ambient() -> None:
|
||||
runtime = _make_runtime()
|
||||
ambient = RunContext(project="proj", branch="feat/ambient")
|
||||
|
||||
resolved = runtime.resolve_message(
|
||||
text="/proj @main do it",
|
||||
reply_text=None,
|
||||
ambient_context=ambient,
|
||||
)
|
||||
|
||||
assert resolved.context == RunContext(project="proj", branch="main")
|
||||
assert resolved.context_source == "directives"
|
||||
|
||||
|
||||
def test_resolve_message_branch_directive_merges_with_ambient_project() -> None:
|
||||
runtime = _make_runtime()
|
||||
ambient = RunContext(project="proj", branch="feat/ambient")
|
||||
|
||||
resolved = runtime.resolve_message(
|
||||
text="@hotfix do it",
|
||||
reply_text=None,
|
||||
ambient_context=ambient,
|
||||
)
|
||||
|
||||
assert resolved.context == RunContext(project="proj", branch="hotfix")
|
||||
assert resolved.context_source == "directives"
|
||||
|
||||
|
||||
def test_resolve_message_project_directive_clears_ambient_branch() -> None:
|
||||
codex = ScriptRunner([Return(answer="ok")], engine="codex")
|
||||
router = AutoRouter(
|
||||
entries=[RunnerEntry(engine=codex.engine, runner=codex)],
|
||||
default_engine=codex.engine,
|
||||
)
|
||||
projects = ProjectsConfig(
|
||||
projects={
|
||||
"proj": ProjectConfig(
|
||||
alias="proj",
|
||||
path=Path("."),
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
),
|
||||
"other": ProjectConfig(
|
||||
alias="other",
|
||||
path=Path("."),
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
),
|
||||
},
|
||||
default_project=None,
|
||||
)
|
||||
runtime = TransportRuntime(router=router, projects=projects)
|
||||
ambient = RunContext(project="proj", branch="feat/ambient")
|
||||
|
||||
resolved = runtime.resolve_message(
|
||||
text="/other do it",
|
||||
reply_text=None,
|
||||
ambient_context=ambient,
|
||||
)
|
||||
|
||||
assert resolved.context == RunContext(project="other", branch=None)
|
||||
assert resolved.context_source == "directives"
|
||||
|
||||
Reference in New Issue
Block a user