feat(telegram): make /ctx work everywhere (#159)
This commit is contained in:
@@ -70,6 +70,8 @@ Takopi will bind the topic and rename it to match the context.
|
||||
- `/ctx set <project> @branch` updates it
|
||||
- `/ctx clear` removes it
|
||||
|
||||
Note: Outside topics (private chats or main group chats), `/ctx` binds the chat context instead of a topic.
|
||||
|
||||
## Reset a topic session
|
||||
|
||||
Use `/new` inside the topic to clear stored sessions for that thread.
|
||||
|
||||
@@ -42,11 +42,17 @@ This line is parsed from replies and takes precedence over new directives.
|
||||
| `/file put <path>` | Upload a document into the repo/worktree (requires file transfer enabled). |
|
||||
| `/file get <path>` | Fetch a file or directory back into Telegram. |
|
||||
| `/topic <project> @branch` | Create/bind a topic (topics enabled). |
|
||||
| `/ctx` | Show topic context binding (topics enabled). |
|
||||
| `/ctx set <project> @branch` | Update topic context binding. |
|
||||
| `/ctx clear` | Remove topic context binding. |
|
||||
| `/ctx` | Show context binding (chat or topic). |
|
||||
| `/ctx set <project> @branch` | Update context binding. |
|
||||
| `/ctx clear` | Remove context binding. |
|
||||
| `/new` | Clear stored sessions for the current scope (topic/chat). |
|
||||
|
||||
Notes:
|
||||
|
||||
- Outside topics, `/ctx` binds the chat context.
|
||||
- In topics, `/ctx` binds the topic context.
|
||||
- `/new` clears sessions but does **not** clear a bound context.
|
||||
|
||||
## CLI
|
||||
|
||||
Takopi’s CLI is an auto-router by default; engine subcommands override the default engine.
|
||||
|
||||
@@ -119,6 +119,9 @@ When a message arrives in a chat whose `chat_id` matches `projects.<alias>.chat_
|
||||
Takopi defaults the project context to that alias unless a reply `ctx:` or explicit
|
||||
`/project` directive is present.
|
||||
|
||||
In non-topic chats, `/ctx` can bind a chat context. That bound context is treated as
|
||||
ambient and takes precedence over the default project mapping until cleared.
|
||||
|
||||
## Worktree resolution
|
||||
|
||||
When `@branch` is present:
|
||||
|
||||
@@ -204,8 +204,8 @@ Commands:
|
||||
- `projects`: `/topic @branch` creates a topic in the project chat and binds it.
|
||||
- `all`: use `/topic <project> @branch` in the main chat, or `/topic @branch` in
|
||||
project chats.
|
||||
- `/ctx` inside a topic shows the bound context and stored session engines.
|
||||
`/ctx set ...` and `/ctx clear` update the binding.
|
||||
- `/ctx` shows the bound context and stored session engines inside topics.
|
||||
Outside topics, `/ctx set ...` and `/ctx clear` bind the chat context.
|
||||
- `/new` inside a topic clears stored resume tokens for that topic.
|
||||
|
||||
State is stored in `telegram_topics_state.json` alongside the config file.
|
||||
|
||||
@@ -31,6 +31,13 @@ Takopi treats the second message as a continuation. If you want a clean slate, u
|
||||
!!! user "You"
|
||||
/new
|
||||
|
||||
To pin a project or branch for the chat, use:
|
||||
|
||||
!!! user "You"
|
||||
/ctx set <project> [@branch]
|
||||
|
||||
`/new` clears the session but keeps the bound context.
|
||||
|
||||
Tip: set a default agent for this chat with `/agent set claude`.
|
||||
|
||||
## Stateless (reply-to-continue)
|
||||
@@ -81,7 +88,7 @@ takopi --onboard
|
||||
## Resume lines in chat mode
|
||||
|
||||
If you enable chat mode (or topics), Takopi can auto-resume, so you can hide resume lines for a cleaner chat.
|
||||
Resume lines are still shown when no project context is set, so replies can branch there.
|
||||
Disable them if you want a fully clean footer, or enable `show_resume_line` to keep reply-branching visible.
|
||||
|
||||
If you prefer always-visible resume lines, set:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
|
||||
from ..context import RunContext
|
||||
from ..logging import get_logger
|
||||
from .engine_overrides import EngineOverrides, normalize_overrides
|
||||
from .state_store import JsonStateStore
|
||||
@@ -17,6 +18,8 @@ STATE_FILENAME = "telegram_chat_prefs_state.json"
|
||||
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
|
||||
default_engine: str | None = None
|
||||
trigger_mode: str | None = None
|
||||
context_project: str | None = None
|
||||
context_branch: str | None = None
|
||||
engine_overrides: dict[str, EngineOverrides] = msgspec.field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -129,6 +132,41 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
||||
async def clear_trigger_mode(self, chat_id: int) -> None:
|
||||
await self.set_trigger_mode(chat_id, None)
|
||||
|
||||
async def get_context(self, chat_id: int) -> RunContext | None:
|
||||
async with self._lock:
|
||||
self._reload_locked_if_needed()
|
||||
chat = self._get_chat_locked(chat_id)
|
||||
if chat is None:
|
||||
return None
|
||||
project = _normalize_text(chat.context_project)
|
||||
if project is None:
|
||||
return None
|
||||
branch = _normalize_text(chat.context_branch)
|
||||
return RunContext(project=project, branch=branch)
|
||||
|
||||
async def set_context(self, chat_id: int, context: RunContext | None) -> None:
|
||||
project = _normalize_text(context.project) if context is not None else None
|
||||
branch = _normalize_text(context.branch) if context is not None else None
|
||||
async with self._lock:
|
||||
self._reload_locked_if_needed()
|
||||
chat = self._get_chat_locked(chat_id)
|
||||
if project is None:
|
||||
if chat is None:
|
||||
return
|
||||
chat.context_project = None
|
||||
chat.context_branch = None
|
||||
if self._chat_is_empty(chat):
|
||||
self._remove_chat_locked(chat_id)
|
||||
self._save_locked()
|
||||
return
|
||||
chat = self._ensure_chat_locked(chat_id)
|
||||
chat.context_project = project
|
||||
chat.context_branch = branch
|
||||
self._save_locked()
|
||||
|
||||
async def clear_context(self, chat_id: int) -> None:
|
||||
await self.set_context(chat_id, None)
|
||||
|
||||
async def get_engine_override(
|
||||
self, chat_id: int, engine: str
|
||||
) -> EngineOverrides | None:
|
||||
@@ -184,6 +222,8 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
|
||||
return (
|
||||
_normalize_text(chat.default_engine) is None
|
||||
and _normalize_trigger_mode(chat.trigger_mode) is None
|
||||
and _normalize_text(chat.context_project) is None
|
||||
and _normalize_text(chat.context_branch) is None
|
||||
and not self._has_engine_overrides(chat.engine_overrides)
|
||||
)
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ def _should_show_resume_line(
|
||||
stateful_mode: bool,
|
||||
context: RunContext | None,
|
||||
) -> bool:
|
||||
if show_resume_line or not stateful_mode:
|
||||
if show_resume_line:
|
||||
return True
|
||||
return context is None or context.project is None
|
||||
return not stateful_mode
|
||||
|
||||
|
||||
async def _send_runner_unavailable(
|
||||
|
||||
@@ -16,6 +16,7 @@ from .model import _handle_model_command as handle_model_command
|
||||
from .parse import _parse_slash_command as parse_slash_command
|
||||
from .reasoning import _handle_reasoning_command as handle_reasoning_command
|
||||
from .topics import _handle_chat_new_command as handle_chat_new_command
|
||||
from .topics import _handle_chat_ctx_command as handle_chat_ctx_command
|
||||
from .topics import _handle_ctx_command as handle_ctx_command
|
||||
from .topics import _handle_new_command as handle_new_command
|
||||
from .topics import _handle_topic_command as handle_topic_command
|
||||
@@ -25,6 +26,7 @@ __all__ = [
|
||||
"dispatch_command",
|
||||
"get_reserved_commands",
|
||||
"handle_agent_command",
|
||||
"handle_chat_ctx_command",
|
||||
"handle_chat_new_command",
|
||||
"handle_ctx_command",
|
||||
"handle_file_command",
|
||||
|
||||
@@ -72,6 +72,7 @@ def build_bot_commands(
|
||||
seen.add(cmd)
|
||||
for cmd, description in [
|
||||
("new", "start a new thread"),
|
||||
("ctx", "show or update context"),
|
||||
("agent", "set default agent"),
|
||||
("model", "set model override"),
|
||||
("reasoning", "set reasoning override"),
|
||||
@@ -82,10 +83,7 @@ def build_bot_commands(
|
||||
commands.append({"command": cmd, "description": description})
|
||||
seen.add(cmd)
|
||||
if include_topics:
|
||||
for cmd, description in [
|
||||
("topic", "create or bind a topic"),
|
||||
("ctx", "show or update topic context"),
|
||||
]:
|
||||
for cmd, description in [("topic", "create or bind a topic")]:
|
||||
if cmd in seen:
|
||||
continue
|
||||
commands.append({"command": cmd, "description": description})
|
||||
|
||||
@@ -2,8 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...context import RunContext
|
||||
from ...markdown import MarkdownParts
|
||||
from ...transport_runtime import TransportRuntime
|
||||
from ...transport import RenderedMessage, SendOptions
|
||||
from ..chat_prefs import ChatPrefsStore
|
||||
from ..chat_sessions import ChatSessionStore
|
||||
from ..context import (
|
||||
_format_context,
|
||||
@@ -116,6 +119,105 @@ async def _handle_ctx_command(
|
||||
)
|
||||
|
||||
|
||||
def _parse_chat_ctx_args(
|
||||
args_text: str,
|
||||
*,
|
||||
runtime: TransportRuntime,
|
||||
default_project: str | None,
|
||||
) -> tuple[RunContext | None, str | None]:
|
||||
tokens = split_command_args(args_text)
|
||||
if not tokens:
|
||||
return None, _usage_ctx_set(chat_project=None)
|
||||
if len(tokens) > 2:
|
||||
return None, "too many arguments"
|
||||
project_token: str | None = None
|
||||
branch: str | None = None
|
||||
first = tokens[0]
|
||||
if first.startswith("@"):
|
||||
branch = first[1:] or None
|
||||
else:
|
||||
project_token = first
|
||||
if len(tokens) == 2:
|
||||
second = tokens[1]
|
||||
if not second.startswith("@"):
|
||||
return None, "branch must be prefixed with @"
|
||||
branch = second[1:] or None
|
||||
project_key: str | None = None
|
||||
if project_token is None:
|
||||
if default_project is None:
|
||||
return None, "project is required"
|
||||
project_key = default_project
|
||||
else:
|
||||
project_key = runtime.normalize_project_key(project_token)
|
||||
if project_key is None:
|
||||
return None, f"unknown project {project_token!r}"
|
||||
return RunContext(project=project_key, branch=branch), None
|
||||
|
||||
|
||||
async def _handle_chat_ctx_command(
|
||||
cfg: TelegramBridgeConfig,
|
||||
msg: TelegramIncomingMessage,
|
||||
args_text: str,
|
||||
chat_prefs: ChatPrefsStore | None,
|
||||
) -> None:
|
||||
reply = make_reply(cfg, msg)
|
||||
if chat_prefs is None:
|
||||
await reply(text="chat context unavailable; config path is not set.")
|
||||
return
|
||||
|
||||
tokens = split_command_args(args_text)
|
||||
action = tokens[0].lower() if tokens else "show"
|
||||
if action in {"show", ""}:
|
||||
bound = await chat_prefs.get_context(msg.chat_id)
|
||||
resolved = cfg.runtime.resolve_message(
|
||||
text="",
|
||||
reply_text=msg.reply_to_text,
|
||||
chat_id=msg.chat_id,
|
||||
ambient_context=bound,
|
||||
)
|
||||
source = resolved.context_source
|
||||
if bound is not None and resolved.context_source == "ambient":
|
||||
source = "bound"
|
||||
lines = [
|
||||
f"bound ctx: {_format_context(cfg.runtime, bound)}",
|
||||
f"resolved ctx: {_format_context(cfg.runtime, resolved.context)} (source: {source})",
|
||||
]
|
||||
if bound is None:
|
||||
ctx_usage = (
|
||||
_usage_ctx_set(chat_project=None).removeprefix("usage: ").strip()
|
||||
)
|
||||
lines.append(f"note: no bound context — bind with {ctx_usage}")
|
||||
await reply(text="\n".join(lines))
|
||||
return
|
||||
if action == "set":
|
||||
rest = " ".join(tokens[1:])
|
||||
context, error = _parse_chat_ctx_args(
|
||||
rest,
|
||||
runtime=cfg.runtime,
|
||||
default_project=cfg.runtime.default_project,
|
||||
)
|
||||
if error is not None:
|
||||
await reply(
|
||||
text=f"error:\n{error}\n{_usage_ctx_set(chat_project=None)}",
|
||||
)
|
||||
return
|
||||
if context is None:
|
||||
await reply(text=f"error:\n{_usage_ctx_set(chat_project=None)}")
|
||||
return
|
||||
await chat_prefs.set_context(msg.chat_id, context)
|
||||
await reply(
|
||||
text=f"chat bound to `{_format_context(cfg.runtime, context)}`",
|
||||
)
|
||||
return
|
||||
if action == "clear":
|
||||
await chat_prefs.clear_context(msg.chat_id)
|
||||
await reply(text="chat context cleared.")
|
||||
return
|
||||
await reply(
|
||||
text="unknown `/ctx` command. use `/ctx`, `/ctx set`, or `/ctx clear`.",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_new_command(
|
||||
cfg: TelegramBridgeConfig,
|
||||
msg: TelegramIncomingMessage,
|
||||
|
||||
@@ -28,6 +28,7 @@ from .commands.file_transfer import FILE_PUT_USAGE
|
||||
from .commands.handlers import (
|
||||
dispatch_command,
|
||||
handle_agent_command,
|
||||
handle_chat_ctx_command,
|
||||
handle_chat_new_command,
|
||||
handle_ctx_command,
|
||||
handle_file_command,
|
||||
@@ -169,8 +170,13 @@ def _dispatch_builtin_command(
|
||||
task_group.start_soon(handler)
|
||||
return True
|
||||
|
||||
if cfg.topics.enabled and topic_store is not None:
|
||||
if command_id == "ctx":
|
||||
topic_key = (
|
||||
_topic_key(msg, cfg, scope_chat_ids=scope_chat_ids)
|
||||
if cfg.topics.enabled and topic_store is not None
|
||||
else None
|
||||
)
|
||||
if topic_key is not None:
|
||||
handler = partial(
|
||||
handle_ctx_command,
|
||||
cfg,
|
||||
@@ -180,7 +186,19 @@ def _dispatch_builtin_command(
|
||||
resolved_scope=resolved_scope,
|
||||
scope_chat_ids=scope_chat_ids,
|
||||
)
|
||||
elif command_id == "new":
|
||||
else:
|
||||
handler = partial(
|
||||
handle_chat_ctx_command,
|
||||
cfg,
|
||||
msg,
|
||||
args_text,
|
||||
chat_prefs,
|
||||
)
|
||||
task_group.start_soon(handler)
|
||||
return True
|
||||
|
||||
if cfg.topics.enabled and topic_store is not None:
|
||||
if command_id == "new":
|
||||
handler = partial(
|
||||
handle_new_command,
|
||||
cfg,
|
||||
@@ -1507,9 +1525,19 @@ async def run_main_loop(
|
||||
if state.topic_store is not None and topic_key is not None
|
||||
else None
|
||||
)
|
||||
chat_bound_context = None
|
||||
if state.chat_prefs is not None:
|
||||
chat_bound_context = await state.chat_prefs.get_context(chat_id)
|
||||
if bound_context is not None:
|
||||
ambient_context = _merge_topic_context(
|
||||
chat_project=chat_project, bound=bound_context
|
||||
)
|
||||
elif chat_bound_context is not None:
|
||||
ambient_context = chat_bound_context
|
||||
else:
|
||||
ambient_context = _merge_topic_context(
|
||||
chat_project=chat_project, bound=None
|
||||
)
|
||||
return TelegramMsgContext(
|
||||
chat_id=chat_id,
|
||||
thread_id=msg.thread_id,
|
||||
|
||||
@@ -135,6 +135,7 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
|
||||
assert {"command": "cancel", "description": "cancel run"} in commands
|
||||
assert {"command": "file", "description": "upload or fetch files"} in commands
|
||||
assert {"command": "new", "description": "start a new thread"} in commands
|
||||
assert {"command": "ctx", "description": "show or update context"} in commands
|
||||
assert {"command": "agent", "description": "set default agent"} in commands
|
||||
assert any(cmd["command"] == "codex" for cmd in commands)
|
||||
|
||||
@@ -179,7 +180,7 @@ def test_build_bot_commands_includes_topics_when_enabled() -> None:
|
||||
commands = build_bot_commands(runtime, include_topics=True)
|
||||
|
||||
assert {"command": "topic", "description": "create or bind a topic"} in commands
|
||||
assert {"command": "ctx", "description": "show or update topic context"} in commands
|
||||
assert {"command": "ctx", "description": "show or update context"} in commands
|
||||
|
||||
|
||||
def test_build_bot_commands_includes_command_plugins(monkeypatch) -> None:
|
||||
@@ -2515,6 +2516,129 @@ async def test_run_main_loop_hides_resume_line_when_disabled(
|
||||
assert resume_value not in final_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_main_loop_hides_resume_line_without_context(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
resume_value = "resume-ctxless"
|
||||
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,
|
||||
forward_coalesce_s=FAST_FORWARD_COALESCE_S,
|
||||
media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S,
|
||||
session_mode="chat",
|
||||
show_resume_line=False,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert transport.send_calls
|
||||
final_text = transport.send_calls[-1]["message"].text
|
||||
assert resume_value not in final_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_main_loop_applies_chat_bound_context(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state_path = tmp_path / "takopi.toml"
|
||||
|
||||
transport = FakeTransport()
|
||||
bot = FakeBot()
|
||||
runner = ScriptRunner([Return(answer="ok")], engine=CODEX_ENGINE)
|
||||
exec_cfg = ExecBridgeConfig(
|
||||
transport=transport,
|
||||
presenter=MarkdownPresenter(),
|
||||
final_notify=True,
|
||||
)
|
||||
projects = ProjectsConfig(
|
||||
projects={
|
||||
"alpha": ProjectConfig(
|
||||
alias="Alpha",
|
||||
path=tmp_path,
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
),
|
||||
"beta": ProjectConfig(
|
||||
alias="Beta",
|
||||
path=tmp_path / "beta",
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
),
|
||||
},
|
||||
default_project="alpha",
|
||||
)
|
||||
(tmp_path / "beta").mkdir()
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=projects,
|
||||
config_path=state_path,
|
||||
)
|
||||
prefs = ChatPrefsStore(resolve_prefs_path(state_path))
|
||||
await prefs.set_context(123, RunContext(project="beta"))
|
||||
cfg = TelegramBridgeConfig(
|
||||
bot=bot,
|
||||
runtime=runtime,
|
||||
chat_id=123,
|
||||
startup_msg="",
|
||||
exec_cfg=exec_cfg,
|
||||
forward_coalesce_s=FAST_FORWARD_COALESCE_S,
|
||||
media_group_debounce_s=FAST_MEDIA_GROUP_DEBOUNCE_S,
|
||||
session_mode="chat",
|
||||
show_resume_line=False,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert transport.send_calls
|
||||
final_text = transport.send_calls[-1]["message"].text
|
||||
assert "`ctx: Beta`" in final_text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_main_loop_chat_sessions_isolate_group_senders(
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -4,8 +4,12 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from takopi.settings import TelegramTopicsSettings
|
||||
from takopi.config import ProjectConfig, ProjectsConfig
|
||||
from takopi.runners.mock import Return, ScriptRunner
|
||||
from takopi.telegram.chat_sessions import ChatSessionStore
|
||||
from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path
|
||||
from takopi.telegram.commands.topics import (
|
||||
_handle_chat_ctx_command,
|
||||
_handle_chat_new_command,
|
||||
_handle_ctx_command,
|
||||
_handle_new_command,
|
||||
@@ -13,7 +17,13 @@ from takopi.telegram.commands.topics import (
|
||||
)
|
||||
from takopi.telegram.topic_state import TopicStateStore
|
||||
from takopi.telegram.types import TelegramIncomingMessage
|
||||
from tests.telegram_fakes import FakeTransport, make_cfg
|
||||
from tests.telegram_fakes import (
|
||||
DEFAULT_ENGINE_ID,
|
||||
FakeTransport,
|
||||
_make_router,
|
||||
make_cfg,
|
||||
)
|
||||
from takopi.transport_runtime import TransportRuntime
|
||||
|
||||
|
||||
def _msg(
|
||||
@@ -37,6 +47,27 @@ def _msg(
|
||||
)
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path) -> tuple[TransportRuntime, Path]:
|
||||
runner = ScriptRunner([Return(answer="ok")], engine=DEFAULT_ENGINE_ID)
|
||||
projects = ProjectsConfig(
|
||||
projects={
|
||||
"alpha": ProjectConfig(
|
||||
alias="Alpha",
|
||||
path=tmp_path,
|
||||
worktrees_dir=Path(".worktrees"),
|
||||
)
|
||||
},
|
||||
default_project="alpha",
|
||||
)
|
||||
state_path = tmp_path / "takopi.toml"
|
||||
runtime = TransportRuntime(
|
||||
router=_make_router(runner),
|
||||
projects=projects,
|
||||
config_path=state_path,
|
||||
)
|
||||
return runtime, state_path
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_command_requires_topic(tmp_path: Path) -> None:
|
||||
transport = FakeTransport()
|
||||
@@ -60,6 +91,33 @@ async def test_ctx_command_requires_topic(tmp_path: Path) -> None:
|
||||
assert "only works inside a topic" in text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_chat_ctx_command_sets_binding(tmp_path: Path) -> None:
|
||||
transport = FakeTransport()
|
||||
runtime, state_path = _runtime(tmp_path)
|
||||
cfg = replace(make_cfg(transport), runtime=runtime, session_mode="chat")
|
||||
store = ChatPrefsStore(resolve_prefs_path(state_path))
|
||||
|
||||
msg = _msg("/ctx set alpha @dev", chat_type="private")
|
||||
await _handle_chat_ctx_command(
|
||||
cfg,
|
||||
msg,
|
||||
args_text="set alpha @dev",
|
||||
chat_prefs=store,
|
||||
)
|
||||
|
||||
msg_show = _msg("/ctx", chat_type="private")
|
||||
await _handle_chat_ctx_command(
|
||||
cfg,
|
||||
msg_show,
|
||||
args_text="",
|
||||
chat_prefs=store,
|
||||
)
|
||||
|
||||
text = transport.send_calls[-1]["message"].text
|
||||
assert "bound ctx: Alpha @dev" in text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_command_requires_topic(tmp_path: Path) -> None:
|
||||
transport = FakeTransport()
|
||||
|
||||
Reference in New Issue
Block a user