Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/coding_agent_telegram/agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,11 +835,17 @@ def _copilot_base(
)
return args

def _claude_base(self, user_message: str) -> list[str]:
def _claude_base(self, user_message: str, *, for_session_creation: bool = False) -> list[str]:
args = []
if self.claude_model:
args.extend(["--model", self.claude_model])
if self.claude_permission_mode:
# Session creation only primes the CLI to hand back a session ID, so it runs
# read-only. Without this the priming prompt inherits full autopilot
# permissions and the agent may act on it (e.g. creating a git branch named
# after the session) behind the bot's back.
if for_session_creation:
args.extend(["--permission-mode", "plan"])
elif self.claude_permission_mode:
args.extend(["--permission-mode", self.claude_permission_mode])
if self.claude_allowed_tools:
args.extend(["--allowedTools", ",".join(self.claude_allowed_tools)])
Expand All @@ -864,9 +870,16 @@ def create_session(
*,
skip_git_repo_check: bool = False,
image_paths: Sequence[Path] = (),
priming_only: bool = False,
on_stall: Optional[Callable[[AgentStallInfo], None]] = None,
on_progress: Optional[Callable[[AgentProgressInfo], None]] = None,
) -> AgentRunResult:
"""Create a session.

``priming_only`` marks calls whose prompt exists solely to make the CLI hand
back a session ID; those run read-only so the throwaway prompt cannot be acted
on. Callers that pass a real user request must leave it False.
"""
if provider == "codex":
args = [
self.codex_bin,
Expand Down Expand Up @@ -894,7 +907,7 @@ def create_session(
on_progress=on_progress,
)
elif provider == "claude":
args = [self.claude_bin, *self._claude_base(user_message)]
args = [self.claude_bin, *self._claude_base(user_message, for_session_creation=priming_only)]
return self._run(
args,
provider="claude",
Expand Down
12 changes: 11 additions & 1 deletion src/coding_agent_telegram/router/session_lifecycle_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
from .base import logger, require_allowed_chat


# Sent only to make the CLI open a session and return its ID. Kept explicitly inert:
# an instruction like "Create session: <name>" reads as a real task to an autonomous
# agent, which would then act on it (e.g. creating a git branch named after the
# session) without the bot knowing.
SESSION_PRIMING_PROMPT = (
"Reply with exactly: ready. Do not make any changes, run any commands, or use any tools."
)


class SessionLifecycleCommandMixin:
_CREATE_SESSION_TEXT_RE = re.compile(r"^\s*create\s+session\s*:\s*(.*?)\s*$", re.IGNORECASE)

Expand Down Expand Up @@ -137,9 +146,10 @@ async def _create_session_for_context(
self.deps.agent_runner.create_session,
provider,
project_path,
f"Create session: {creation_label}",
SESSION_PRIMING_PROMPT,
workspace_lock_key=project_folder,
skip_git_repo_check=self.runtime.should_skip_git_repo_check(project_folder),
priming_only=True,
stall_message=self._t(update, "runtime.replacement_session_stall"),
)

Expand Down
3 changes: 3 additions & 0 deletions src/coding_agent_telegram/session_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ async def compact_active_session(
COMPACT_BOOTSTRAP_TEMPLATE.format(summary=compact_summary),
workspace_lock_key=project_folder,
skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
# Seeds context and returns a session ID only. The summary lists "next
# steps", which an autopilot agent would otherwise start executing here.
priming_only=True,
stall_message=self._t(update, "runtime.replacement_session_stall"),
progress_label=self._t(update, "runtime.live_agent_output"),
)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,63 @@ def test_claude_runner_ignores_image_paths_without_error(monkeypatch):
assert result.success is True


def test_claude_priming_session_creation_runs_read_only(monkeypatch):
calls = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))

runner = MultiAgentRunner(
codex_bin="codex",
copilot_bin="copilot",
approval_policy="never",
sandbox_mode="workspace-write",
claude_permission_mode="bypassPermissions",
)

runner.create_session("claude", Path("/tmp/project"), "prime me", priming_only=True)

args = calls[0][0]
assert args[args.index("--permission-mode") + 1] == "plan"
assert "bypassPermissions" not in args


def test_claude_session_creation_with_real_prompt_keeps_configured_permission_mode(monkeypatch):
calls = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))

runner = MultiAgentRunner(
codex_bin="codex",
copilot_bin="copilot",
approval_policy="never",
sandbox_mode="workspace-write",
claude_permission_mode="bypassPermissions",
)

# The replacement-session path passes the real user request here, so it must not
# be downgraded to read-only.
runner.create_session("claude", Path("/tmp/project"), "fix the bug")

args = calls[0][0]
assert args[args.index("--permission-mode") + 1] == "bypassPermissions"


def test_claude_resume_keeps_configured_permission_mode(monkeypatch):
calls = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))

runner = MultiAgentRunner(
codex_bin="codex",
copilot_bin="copilot",
approval_policy="never",
sandbox_mode="workspace-write",
claude_permission_mode="bypassPermissions",
)

runner.resume_session("claude", "sess_abc", Path("/tmp/project"), "keep working")

args = calls[0][0]
assert args[args.index("--permission-mode") + 1] == "bypassPermissions"


# ---------------------------------------------------------------------------
# _validate_session_id
# ---------------------------------------------------------------------------
Expand Down
11 changes: 8 additions & 3 deletions tests/test_command_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import pytest
from coding_agent_telegram.agent_runner import AgentProgressInfo, AgentRunResult, AgentStallInfo
from coding_agent_telegram.command_router import CommandRouter, RouterDeps
from coding_agent_telegram.router.session_lifecycle_commands import SESSION_PRIMING_PROMPT
from coding_agent_telegram.config import AppConfig
from coding_agent_telegram.session_store import SessionStore
from coding_agent_telegram.speech_to_text import SpeechToTextError
Expand All @@ -32,6 +33,7 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
priming_only=False,
on_stall=None,
on_progress=None,
):
Expand Down Expand Up @@ -128,6 +130,7 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
priming_only=False,
on_stall=None,
on_progress=None,
):
Expand Down Expand Up @@ -1187,6 +1190,7 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
priming_only=False,
on_stall=None,
on_progress=None,
):
Expand Down Expand Up @@ -1565,7 +1569,7 @@ def test_new_without_name_uses_new_session_as_default_name(tmp_path: Path):
state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123"
assert "Session created successfully: sess_abc123" in bot.messages[-1][1]
assert runner.create_calls[-1]["user_message"] == "Create session: new session"
assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT


def test_new_without_name_ignores_existing_new_session_labels(tmp_path: Path):
Expand Down Expand Up @@ -1610,7 +1614,7 @@ def test_plain_text_create_session_new_session_uses_unnamed_flow(tmp_path: Path)

state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123"
assert runner.create_calls[-1]["user_message"] == "Create session: new session"
assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT


def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path):
Expand All @@ -1632,7 +1636,7 @@ def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path)

state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "release prep"
assert runner.create_calls[-1]["user_message"] == "Create session: release prep"
assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT


def test_provider_command_sends_inline_buttons(tmp_path: Path):
Expand Down Expand Up @@ -8147,6 +8151,7 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
priming_only=False,
on_stall=None,
on_progress=None,
):
Expand Down
Loading