feat(telegram): make /ctx work everywhere (#159)

This commit is contained in:
banteg
2026-01-17 01:17:50 +04:00
committed by GitHub
parent b215279a3c
commit 419ec5078b
13 changed files with 390 additions and 20 deletions
+2
View File
@@ -70,6 +70,8 @@ Takopi will bind the topic and rename it to match the context.
- `/ctx set <project> @branch` updates it - `/ctx set <project> @branch` updates it
- `/ctx clear` removes 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 ## Reset a topic session
Use `/new` inside the topic to clear stored sessions for that thread. Use `/new` inside the topic to clear stored sessions for that thread.
+9 -3
View File
@@ -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 put <path>` | Upload a document into the repo/worktree (requires file transfer enabled). |
| `/file get <path>` | Fetch a file or directory back into Telegram. | | `/file get <path>` | Fetch a file or directory back into Telegram. |
| `/topic <project> @branch` | Create/bind a topic (topics enabled). | | `/topic <project> @branch` | Create/bind a topic (topics enabled). |
| `/ctx` | Show topic context binding (topics enabled). | | `/ctx` | Show context binding (chat or topic). |
| `/ctx set <project> @branch` | Update topic context binding. | | `/ctx set <project> @branch` | Update context binding. |
| `/ctx clear` | Remove topic context binding. | | `/ctx clear` | Remove context binding. |
| `/new` | Clear stored sessions for the current scope (topic/chat). | | `/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 ## CLI
Takopis CLI is an auto-router by default; engine subcommands override the default engine. Takopis CLI is an auto-router by default; engine subcommands override the default engine.
+3
View File
@@ -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 Takopi defaults the project context to that alias unless a reply `ctx:` or explicit
`/project` directive is present. `/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 ## Worktree resolution
When `@branch` is present: When `@branch` is present:
+2 -2
View File
@@ -204,8 +204,8 @@ Commands:
- `projects`: `/topic @branch` creates a topic in the project chat and binds it. - `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 - `all`: use `/topic <project> @branch` in the main chat, or `/topic @branch` in
project chats. project chats.
- `/ctx` inside a topic shows the bound context and stored session engines. - `/ctx` shows the bound context and stored session engines inside topics.
`/ctx set ...` and `/ctx clear` update the binding. Outside topics, `/ctx set ...` and `/ctx clear` bind the chat context.
- `/new` inside a topic clears stored resume tokens for that topic. - `/new` inside a topic clears stored resume tokens for that topic.
State is stored in `telegram_topics_state.json` alongside the config file. State is stored in `telegram_topics_state.json` alongside the config file.
+8 -1
View File
@@ -31,6 +31,13 @@ Takopi treats the second message as a continuation. If you want a clean slate, u
!!! user "You" !!! user "You"
/new /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`. Tip: set a default agent for this chat with `/agent set claude`.
## Stateless (reply-to-continue) ## Stateless (reply-to-continue)
@@ -81,7 +88,7 @@ takopi --onboard
## Resume lines in chat mode ## 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. 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: If you prefer always-visible resume lines, set:
+40
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import msgspec import msgspec
from ..context import RunContext
from ..logging import get_logger from ..logging import get_logger
from .engine_overrides import EngineOverrides, normalize_overrides from .engine_overrides import EngineOverrides, normalize_overrides
from .state_store import JsonStateStore from .state_store import JsonStateStore
@@ -17,6 +18,8 @@ STATE_FILENAME = "telegram_chat_prefs_state.json"
class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False): class _ChatPrefs(msgspec.Struct, forbid_unknown_fields=False):
default_engine: str | None = None default_engine: str | None = None
trigger_mode: 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) 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: async def clear_trigger_mode(self, chat_id: int) -> None:
await self.set_trigger_mode(chat_id, 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( async def get_engine_override(
self, chat_id: int, engine: str self, chat_id: int, engine: str
) -> EngineOverrides | None: ) -> EngineOverrides | None:
@@ -184,6 +222,8 @@ class ChatPrefsStore(JsonStateStore[_ChatPrefsState]):
return ( return (
_normalize_text(chat.default_engine) is None _normalize_text(chat.default_engine) is None
and _normalize_trigger_mode(chat.trigger_mode) 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) and not self._has_engine_overrides(chat.engine_overrides)
) )
+2 -2
View File
@@ -109,9 +109,9 @@ def _should_show_resume_line(
stateful_mode: bool, stateful_mode: bool,
context: RunContext | None, context: RunContext | None,
) -> bool: ) -> bool:
if show_resume_line or not stateful_mode: if show_resume_line:
return True return True
return context is None or context.project is None return not stateful_mode
async def _send_runner_unavailable( async def _send_runner_unavailable(
+2
View File
@@ -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 .parse import _parse_slash_command as parse_slash_command
from .reasoning import _handle_reasoning_command as handle_reasoning_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_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_ctx_command as handle_ctx_command
from .topics import _handle_new_command as handle_new_command from .topics import _handle_new_command as handle_new_command
from .topics import _handle_topic_command as handle_topic_command from .topics import _handle_topic_command as handle_topic_command
@@ -25,6 +26,7 @@ __all__ = [
"dispatch_command", "dispatch_command",
"get_reserved_commands", "get_reserved_commands",
"handle_agent_command", "handle_agent_command",
"handle_chat_ctx_command",
"handle_chat_new_command", "handle_chat_new_command",
"handle_ctx_command", "handle_ctx_command",
"handle_file_command", "handle_file_command",
+2 -4
View File
@@ -72,6 +72,7 @@ def build_bot_commands(
seen.add(cmd) seen.add(cmd)
for cmd, description in [ for cmd, description in [
("new", "start a new thread"), ("new", "start a new thread"),
("ctx", "show or update context"),
("agent", "set default agent"), ("agent", "set default agent"),
("model", "set model override"), ("model", "set model override"),
("reasoning", "set reasoning override"), ("reasoning", "set reasoning override"),
@@ -82,10 +83,7 @@ def build_bot_commands(
commands.append({"command": cmd, "description": description}) commands.append({"command": cmd, "description": description})
seen.add(cmd) seen.add(cmd)
if include_topics: if include_topics:
for cmd, description in [ for cmd, description in [("topic", "create or bind a topic")]:
("topic", "create or bind a topic"),
("ctx", "show or update topic context"),
]:
if cmd in seen: if cmd in seen:
continue continue
commands.append({"command": cmd, "description": description}) commands.append({"command": cmd, "description": description})
+102
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...context import RunContext
from ...markdown import MarkdownParts from ...markdown import MarkdownParts
from ...transport_runtime import TransportRuntime
from ...transport import RenderedMessage, SendOptions from ...transport import RenderedMessage, SendOptions
from ..chat_prefs import ChatPrefsStore
from ..chat_sessions import ChatSessionStore from ..chat_sessions import ChatSessionStore
from ..context import ( from ..context import (
_format_context, _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( async def _handle_new_command(
cfg: TelegramBridgeConfig, cfg: TelegramBridgeConfig,
msg: TelegramIncomingMessage, msg: TelegramIncomingMessage,
+34 -6
View File
@@ -28,6 +28,7 @@ from .commands.file_transfer import FILE_PUT_USAGE
from .commands.handlers import ( from .commands.handlers import (
dispatch_command, dispatch_command,
handle_agent_command, handle_agent_command,
handle_chat_ctx_command,
handle_chat_new_command, handle_chat_new_command,
handle_ctx_command, handle_ctx_command,
handle_file_command, handle_file_command,
@@ -169,8 +170,13 @@ def _dispatch_builtin_command(
task_group.start_soon(handler) task_group.start_soon(handler)
return True return True
if cfg.topics.enabled and topic_store is not None: if command_id == "ctx":
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( handler = partial(
handle_ctx_command, handle_ctx_command,
cfg, cfg,
@@ -180,7 +186,19 @@ def _dispatch_builtin_command(
resolved_scope=resolved_scope, resolved_scope=resolved_scope,
scope_chat_ids=scope_chat_ids, 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( handler = partial(
handle_new_command, handle_new_command,
cfg, cfg,
@@ -1507,9 +1525,19 @@ async def run_main_loop(
if state.topic_store is not None and topic_key is not None if state.topic_store is not None and topic_key is not None
else None else None
) )
ambient_context = _merge_topic_context( chat_bound_context = None
chat_project=chat_project, bound=bound_context 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( return TelegramMsgContext(
chat_id=chat_id, chat_id=chat_id,
thread_id=msg.thread_id, thread_id=msg.thread_id,
+125 -1
View File
@@ -135,6 +135,7 @@ def test_build_bot_commands_includes_cancel_and_engine() -> None:
assert {"command": "cancel", "description": "cancel run"} in commands assert {"command": "cancel", "description": "cancel run"} in commands
assert {"command": "file", "description": "upload or fetch files"} in commands assert {"command": "file", "description": "upload or fetch files"} in commands
assert {"command": "new", "description": "start a new thread"} 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 {"command": "agent", "description": "set default agent"} in commands
assert any(cmd["command"] == "codex" for cmd 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) commands = build_bot_commands(runtime, include_topics=True)
assert {"command": "topic", "description": "create or bind a topic"} in commands 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: 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 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 @pytest.mark.anyio
async def test_run_main_loop_chat_sessions_isolate_group_senders( async def test_run_main_loop_chat_sessions_isolate_group_senders(
tmp_path: Path, tmp_path: Path,
+59 -1
View File
@@ -4,8 +4,12 @@ from pathlib import Path
import pytest import pytest
from takopi.settings import TelegramTopicsSettings 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_sessions import ChatSessionStore
from takopi.telegram.chat_prefs import ChatPrefsStore, resolve_prefs_path
from takopi.telegram.commands.topics import ( from takopi.telegram.commands.topics import (
_handle_chat_ctx_command,
_handle_chat_new_command, _handle_chat_new_command,
_handle_ctx_command, _handle_ctx_command,
_handle_new_command, _handle_new_command,
@@ -13,7 +17,13 @@ from takopi.telegram.commands.topics import (
) )
from takopi.telegram.topic_state import TopicStateStore from takopi.telegram.topic_state import TopicStateStore
from takopi.telegram.types import TelegramIncomingMessage 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( 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 @pytest.mark.anyio
async def test_ctx_command_requires_topic(tmp_path: Path) -> None: async def test_ctx_command_requires_topic(tmp_path: Path) -> None:
transport = FakeTransport() 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 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 @pytest.mark.anyio
async def test_new_command_requires_topic(tmp_path: Path) -> None: async def test_new_command_requires_topic(tmp_path: Path) -> None:
transport = FakeTransport() transport = FakeTransport()