feat(perm): 添加会话权限模式管理功能

实现会话权限模式管理功能,包括:
1. 在 pty_process 中定义三种权限模式标志
2. 添加 /perm 命令用于修改会话权限模式
3. 新增 run_command 工具用于执行 bot 控制命令
4. 在会话管理中支持权限模式设置
5. 添加完整的测试用例和文档说明
This commit is contained in:
Yuyao Huang (Sam) 2026-03-29 06:46:45 +08:00
parent 8dab229aaf
commit cbeafa35a5
8 changed files with 302 additions and 60 deletions

View File

@ -10,7 +10,7 @@ from dataclasses import dataclass, field, asdict
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from agent.pty_process import run_claude from agent.pty_process import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES
from agent.audit import log_interaction from agent.audit import log_interaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,6 +30,7 @@ class Session:
started: bool = False started: bool = False
idle_timeout: int = DEFAULT_IDLE_TIMEOUT idle_timeout: int = DEFAULT_IDLE_TIMEOUT
cc_timeout: float = DEFAULT_CC_TIMEOUT cc_timeout: float = DEFAULT_CC_TIMEOUT
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE)
def touch(self) -> None: def touch(self) -> None:
self.last_activity = asyncio.get_event_loop().time() self.last_activity = asyncio.get_event_loop().time()
@ -70,6 +71,7 @@ class SessionManager:
owner_id: str = "", owner_id: str = "",
idle_timeout: int = DEFAULT_IDLE_TIMEOUT, idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
cc_timeout: float = DEFAULT_CC_TIMEOUT, cc_timeout: float = DEFAULT_CC_TIMEOUT,
permission_mode: str = DEFAULT_PERMISSION_MODE,
) -> Session: ) -> Session:
async with self._lock: async with self._lock:
session = Session( session = Session(
@ -78,12 +80,14 @@ class SessionManager:
owner_id=owner_id, owner_id=owner_id,
idle_timeout=idle_timeout, idle_timeout=idle_timeout,
cc_timeout=cc_timeout, cc_timeout=cc_timeout,
permission_mode=permission_mode,
) )
self._sessions[conv_id] = session self._sessions[conv_id] = session
self._save() self._save()
logger.info( logger.info(
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs)", "Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)",
conv_id, owner_id[-8:] if owner_id else "-", working_dir, idle_timeout, cc_timeout, conv_id, owner_id[-8:] if owner_id else "-", working_dir,
idle_timeout, cc_timeout, permission_mode,
) )
return session return session
@ -98,6 +102,7 @@ class SessionManager:
cwd = session.cwd cwd = session.cwd
cc_session_id = session.cc_session_id cc_session_id = session.cc_session_id
cc_timeout = session.cc_timeout cc_timeout = session.cc_timeout
permission_mode = session.permission_mode
first_message = not session.started first_message = not session.started
if first_message: if first_message:
session.started = True session.started = True
@ -116,6 +121,7 @@ class SessionManager:
cc_session_id=cc_session_id, cc_session_id=cc_session_id,
resume=not first_message, resume=not first_message,
timeout=cc_timeout, timeout=cc_timeout,
permission_mode=permission_mode,
) )
log_interaction( log_interaction(
conv_id=conv_id, conv_id=conv_id,
@ -158,6 +164,7 @@ class SessionManager:
cc_session_id=cc_session_id, cc_session_id=cc_session_id,
resume=not first_message, resume=not first_message,
timeout=cc_timeout, timeout=cc_timeout,
permission_mode=permission_mode,
) )
log_interaction( log_interaction(
@ -195,10 +202,24 @@ class SessionManager:
"started": s.started, "started": s.started,
"idle_timeout": s.idle_timeout, "idle_timeout": s.idle_timeout,
"cc_timeout": s.cc_timeout, "cc_timeout": s.cc_timeout,
"permission_mode": s.permission_mode,
} }
for s in sessions for s in sessions
] ]
def set_permission_mode(self, conv_id: str, mode: str, user_id: Optional[str] = None) -> None:
"""Change the permission mode for an existing session."""
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
if mode not in VALID_PERMISSION_MODES:
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
session.permission_mode = mode
self._save()
logger.info("Set permission_mode=%s for session %s", mode, conv_id)
def _save(self) -> None: def _save(self) -> None:
try: try:
data = {cid: s.to_dict() for cid, s in self._sessions.items()} data = {cid: s.to_dict() for cid, s in self._sessions.items()}

View File

@ -17,12 +17,22 @@ def strip_ansi(text: str) -> str:
return ANSI_ESCAPE.sub("", text) return ANSI_ESCAPE.sub("", text)
PERMISSION_MODE_FLAGS: dict[str, list[str]] = {
"bypassPermissions": ["--dangerously-skip-permissions"],
"acceptEdits": ["--permission-mode", "acceptEdits"],
"plan": ["--permission-mode", "plan"],
}
VALID_PERMISSION_MODES = list(PERMISSION_MODE_FLAGS)
DEFAULT_PERMISSION_MODE = "bypassPermissions"
async def run_claude( async def run_claude(
prompt: str, prompt: str,
cwd: str, cwd: str,
cc_session_id: str | None = None, cc_session_id: str | None = None,
resume: bool = False, resume: bool = False,
timeout: float = 300.0, timeout: float = 300.0,
permission_mode: str = DEFAULT_PERMISSION_MODE,
) -> str: ) -> str:
""" """
Run `claude -p <prompt>` in the given directory and return the output. Run `claude -p <prompt>` in the given directory and return the output.
@ -35,11 +45,10 @@ async def run_claude(
- Subsequent calls: passed as --resume so CC has full history. - Subsequent calls: passed as --resume so CC has full history.
resume: If True, use --resume instead of --session-id. resume: If True, use --resume instead of --session-id.
timeout: Maximum seconds to wait before giving up. timeout: Maximum seconds to wait before giving up.
permission_mode: One of 'bypassPermissions', 'acceptEdits', 'plan'.
""" """
base_args = [ perm_flags = PERMISSION_MODE_FLAGS.get(permission_mode, PERMISSION_MODE_FLAGS[DEFAULT_PERMISSION_MODE])
"--dangerously-skip-permissions", base_args = perm_flags + ["-p", prompt]
"-p", prompt,
]
if cc_session_id: if cc_session_id:
if resume: if resume:

View File

@ -12,11 +12,35 @@ from typing import Optional, Tuple
from agent.manager import manager from agent.manager import manager
from agent.scheduler import scheduler from agent.scheduler import scheduler
from agent.task_runner import task_runner from agent.task_runner import task_runner
from agent.pty_process import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from orchestrator.agent import agent from orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_chat from orchestrator.tools import set_current_user, get_current_chat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Permission mode aliases (user-facing shorthand → internal CC mode)
_PERM_ALIASES: dict[str, str] = {
"bypass": "bypassPermissions",
"skip": "bypassPermissions",
"accept": "acceptEdits",
"plan": "plan",
}
_PERM_LABELS: dict[str, str] = {
"bypassPermissions": "bypass",
"acceptEdits": "accept",
"plan": "plan",
}
def _resolve_perm(alias: str) -> str | None:
"""Map user-facing alias to internal permission mode, or None if invalid."""
return _PERM_ALIASES.get(alias.lower().strip())
def _perm_label(mode: str) -> str:
"""Return short human-readable label for a permission mode."""
return _PERM_LABELS.get(mode, mode)
def parse_command(text: str) -> Optional[Tuple[str, str]]: def parse_command(text: str) -> Optional[Tuple[str, str]]:
""" """
@ -67,6 +91,8 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
return await _cmd_shell(args) return await _cmd_shell(args)
elif cmd == "/remind": elif cmd == "/remind":
return await _cmd_remind(args) return await _cmd_remind(args)
elif cmd == "/perm":
return await _cmd_perm(user_id, args)
elif cmd in ("/nodes", "/node"): elif cmd in ("/nodes", "/node"):
return await _cmd_nodes(user_id, args) return await _cmd_nodes(user_id, args)
else: else:
@ -76,61 +102,71 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
async def _cmd_new(user_id: str, args: str) -> str: async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session.""" """Create a new session."""
if not args: if not args:
return "Usage: /new <project_dir> [initial_message] [--timeout N]\nExample: /new todo_app fix the bug --timeout 600" return "Usage: /new <project_dir> [initial_message] [--timeout N] [--perm MODE]\nModes: bypass (default), accept, plan"
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("working_dir", nargs="?", help="Project directory") parser.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message") parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds") parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds") parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
parser.add_argument("--perm", default=None, help="Permission mode: bypass, accept, plan")
try: try:
parsed = parser.parse_args(args.split()) parsed = parser.parse_args(args.split())
except SystemExit: except SystemExit:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N]" return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N] [--perm MODE]"
if not parsed.working_dir: if not parsed.working_dir:
return "Error: project_dir is required" return "Error: project_dir is required"
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
if permission_mode is None:
return f"Invalid --perm. Valid modes: bypass, accept, plan"
working_dir = parsed.working_dir working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None initial_msg = " ".join(parsed.rest) if parsed.rest else None
from orchestrator.tools import CreateConversationTool from orchestrator.tools import _resolve_dir
tool = CreateConversationTool()
result = await tool._arun(
working_dir=working_dir,
initial_message=initial_msg,
cc_timeout=parsed.timeout,
idle_timeout=parsed.idle,
)
try: try:
data = json.loads(result) resolved = _resolve_dir(working_dir)
if "error" in data: except ValueError as e:
return f"Error: {data['error']}" return f"Error: {e}"
conv_id = data.get("conv_id", "")
import uuid as _uuid
conv_id = str(_uuid.uuid4())[:8]
await manager.create(
conv_id,
str(resolved),
owner_id=user_id,
idle_timeout=parsed.idle or 1800,
cc_timeout=float(parsed.timeout or 300),
permission_mode=permission_mode,
)
agent._active_conv[user_id] = conv_id agent._active_conv[user_id] = conv_id
cwd = data.get("working_dir", working_dir)
response = None
if initial_msg:
response = await manager.send(conv_id, initial_msg, user_id=user_id)
chat_id = get_current_chat() chat_id = get_current_chat()
if chat_id: if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id) sessions = manager.list_sessions(user_id=user_id)
mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪" routing_mode = "Direct 🟢" if agent.get_passthrough(user_id) else "Smart ⚪"
card = build_sessions_card(sessions, conv_id, mode) card = build_sessions_card(sessions, conv_id, routing_mode)
await send_card(chat_id, "chat_id", card) await send_card(chat_id, "chat_id", card)
if initial_msg and data.get("response"): if initial_msg and response:
await send_text(chat_id, "chat_id", data["response"]) await send_text(chat_id, "chat_id", response)
return "" return ""
reply = f"✓ Created session `{conv_id}` in `{cwd}`" perm_label = _perm_label(permission_mode)
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
if parsed.timeout: if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)" reply += f" (timeout: {parsed.timeout}s)"
if initial_msg: if initial_msg and response:
reply += f"\n\nSent: {initial_msg[:100]}..." reply += f"\n\n{response}"
return reply return reply
except Exception:
return result
async def _cmd_status(user_id: str) -> str: async def _cmd_status(user_id: str) -> str:
@ -152,7 +188,8 @@ async def _cmd_status(user_id: str) -> str:
lines = ["**Your Sessions:**\n"] lines = ["**Your Sessions:**\n"]
for i, s in enumerate(sessions, 1): for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active else " " marker = "" if s["conv_id"] == active else " "
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}`") perm = _perm_label(s.get("permission_mode", DEFAULT_PERMISSION_MODE))
lines.append(f"{marker}{i}. `{s['conv_id']}` - `{s['cwd']}` [{perm}]")
lines.append(f"\n**Mode:** {mode}") lines.append(f"\n**Mode:** {mode}")
lines.append("Use `/switch <n>` to activate a session.") lines.append("Use `/switch <n>` to activate a session.")
lines.append("Use `/direct` or `/smart` to change mode.") lines.append("Use `/direct` or `/smart` to change mode.")
@ -234,6 +271,38 @@ async def _cmd_switch(user_id: str, args: str) -> str:
return f"Invalid number: {args}" return f"Invalid number: {args}"
async def _cmd_perm(user_id: str, args: str) -> str:
"""Change the permission mode of the active (or specified) session."""
parts = args.split()
if not parts:
return (
"Usage: /perm <mode> [conv_id]\n"
"Modes: bypass (default), accept, plan\n"
" bypass — skip all permission checks\n"
" accept — auto-accept file edits, confirm shell commands\n"
" plan — plan only, no writes"
)
alias = parts[0]
conv_id = parts[1] if len(parts) > 1 else agent.get_active_conv(user_id)
permission_mode = _resolve_perm(alias)
if permission_mode is None:
return f"Unknown mode '{alias}'. Valid: bypass, accept, plan"
if not conv_id:
return "No active session. Use `/perm <mode> <conv_id>` or activate a session first."
try:
manager.set_permission_mode(conv_id, permission_mode, user_id=user_id)
except KeyError:
return f"Session `{conv_id}` not found."
except PermissionError as e:
return str(e)
return f"✓ Session `{conv_id}` permission mode set to **{_perm_label(permission_mode)}**"
async def _cmd_retry(user_id: str) -> str: async def _cmd_retry(user_id: str) -> str:
"""Retry the last message (placeholder - needs history tracking).""" """Retry the last message (placeholder - needs history tracking)."""
return "Retry not yet implemented. Just send your message again." return "Retry not yet implemented. Just send your message again."
@ -357,10 +426,11 @@ async def _cmd_nodes(user_id: str, args: str) -> str:
def _cmd_help() -> str: def _cmd_help() -> str:
"""Show help.""" """Show help."""
return """**Commands:** return """**Commands:**
/new <dir> [msg] [--timeout N] [--idle N] - Create session /new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
/status - Show sessions and current mode /status - Show sessions and current mode
/close [n] - Close session (active or by number) /close [n] - Close session (active or by number)
/switch <n> - Switch to session by number /switch <n> - Switch to session by number
/perm <mode> [conv_id] - Set permission mode (bypass/accept/plan)
/direct - Direct mode: messages Claude Code (no LLM overhead) /direct - Direct mode: messages Claude Code (no LLM overhead)
/smart - Smart mode: messages LLM routing (default) /smart - Smart mode: messages LLM routing (default)
/shell <cmd> - Run shell command (bypasses LLM) /shell <cmd> - Run shell command (bypasses LLM)
@ -369,4 +439,12 @@ def _cmd_help() -> str:
/nodes - List connected host nodes /nodes - List connected host nodes
/node <name> - Switch active node /node <name> - Switch active node
/retry - Retry last message /retry - Retry last message
/help - Show this help""" /help - Show this help
**Permission modes** (used by /perm and /new --perm):
bypass 跳过所有权限确认CC 自动执行一切操作默认
适合受信任的沙盒环境自动化任务
accept 自动接受文件编辑 shell 命令仍需手动确认
适合日常开发需要对命令执行保持控制
plan 只规划不执行任何写操作
适合先预览 CC 的操作计划再决定是否执行"""

View File

@ -40,28 +40,45 @@ Pass these names directly to `create_conversation` — the tool resolves them au
{active_session_line} {active_session_line}
Your responsibilities: ## Tools — two distinct categories
### Bot control commands (use `run_command`)
`run_command` executes PhoneWork slash commands. Use it when the user asks to:
- Change permission mode: "切换到只读模式" run_command("/perm plan")
- Close/switch sessions: "关掉第一个" run_command("/close 1")
- Change routing mode: "切换到直连模式" run_command("/direct")
- Set a reminder: "10分钟后提醒我" run_command("/remind 10m 提醒我")
- Check status: "看看现在有哪些 session" run_command("/status")
- Any other /command the user would type manually
Available bot commands (pass verbatim to run_command):
/new <dir> [msg] [--perm bypass|accept|plan] create session
/close [n|conv_id] close session
/switch <n> switch active session
/perm <mode> [conv_id] permission mode: bypass (default), accept, plan
/direct direct mode (bypass LLM for CC messages)
/smart smart mode (LLM routing, default)
/status list sessions
/remind <Ns|Nm|Nh> <msg> one-shot reminder
/tasks list background tasks
### Host shell commands (use `run_shell`)
`run_shell` executes shell commands on the host machine (git, ls, cat, pip, etc.).
NEVER use `run_shell` for bot control. NEVER use `run_command` for shell commands.
## Session responsibilities
1. NEW session: call `create_conversation` with the project name/path. \ 1. NEW session: call `create_conversation` with the project name/path. \
If the user's message also contains a task, pass it as `initial_message` too. If the user's message also contains a task, pass it as `initial_message` too.
2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above. 2. Follow-up to ACTIVE session: call `send_to_conversation` with the active conv_id shown above.
3. List sessions: call `list_conversations`. 3. BOT CONTROL: call `run_command` with the appropriate slash command.
4. Close session: call `close_conversation`. 4. GENERAL QUESTIONS: answer directly do NOT create a session for simple Q&A.
5. GENERAL QUESTIONS: If the user asks a general question (not about a specific project or file), \ 5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
answer directly using your own knowledge. Do NOT create a session for simple Q&A. 6. BACKGROUND TASKS: when a task starts, reply immediately do NOT poll `task_status`.
6. WEB / SEARCH: Use the `web` tool when the user needs current information. \
Call it ONCE (or at most twice with a refined query). Then synthesize and reply \
do NOT keep searching in a loop. If the first search returns results, use them.
7. BACKGROUND TASKS: When `create_conversation` or `send_to_conversation` returns a \
"Task #... started" message, the task is running in the background. \
Immediately reply to the user that the task has started and they will be notified. \
Do NOT call `task_status` in a loop waiting for it the system sends a notification when done.
Guidelines: Guidelines:
- Relay Claude Code's output verbatim. - Relay Claude Code's output verbatim.
- If no active session and the user sends a task without naming a directory, ask them which project. - If no active session and the user sends a task without naming a directory, ask which project.
- For general knowledge questions (e.g., "what is a Python generator?", "explain async/await"), \ - After using any tool, always produce a final text reply. Never end a turn on a tool call.
answer directly without creating a session.
- After using any tool, always produce a final text reply to the user. Never end a turn on a tool call.
- Keep your own words brief let Claude Code's output speak. - Keep your own words brief let Claude Code's output speak.
- Reply in the same language the user uses (Chinese or English). - Reply in the same language the user uses (Chinese or English).
""" """

View File

@ -716,12 +716,49 @@ class TaskStatusTool(BaseTool):
}, ensure_ascii=False) }, ensure_ascii=False)
class RunCommandInput(BaseModel):
command: str = Field(
...,
description=(
"A bot slash command to execute (e.g. '/perm accept', '/close 1', '/switch 2'). "
"This runs bot control commands — NOT shell commands on the host machine. "
"Use run_shell for host shell commands (git, ls, etc.)."
),
)
class RunCommandTool(BaseTool):
name: str = "run_command"
description: str = (
"Execute a PhoneWork bot slash command on behalf of the user. "
"Use this to control sessions, switch modes, change permissions, etc. "
"Examples: '/perm accept', '/close 1', '/switch 2', '/direct', '/smart', '/status'. "
"Do NOT use this for shell commands — use run_shell for those."
)
args_schema: Type[BaseModel] = RunCommandInput
def _run(self, command: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, command: str) -> str:
from bot.commands import handle_command
from orchestrator.tools import get_current_user
user_id = get_current_user()
if not user_id:
return "Error: no user context"
result = await handle_command(user_id, command.strip())
if result is None:
return f"Unknown command: {command!r}. Use /help to see available commands."
return result
# Module-level tool list for easy import # Module-level tool list for easy import
TOOLS = [ TOOLS = [
CreateConversationTool(), CreateConversationTool(),
SendToConversationTool(), SendToConversationTool(),
ListConversationsTool(), ListConversationsTool(),
CloseConversationTool(), CloseConversationTool(),
RunCommandTool(),
ShellTool(), ShellTool(),
FileReadTool(), FileReadTool(),
FileWriteTool(), FileWriteTool(),

View File

@ -0,0 +1,70 @@
Feature: /perm command — change session permission mode
Background:
Given user "user_abc123" is sending commands
Scenario: No args shows usage
When user sends "/perm"
Then reply contains "Usage"
And reply contains "bypass"
And reply contains "accept"
And reply contains "plan"
Scenario: Set active session to accept mode
Given user has session "sess01" in "/tmp/proj1"
And active session is "sess01"
When user sends "/perm accept"
Then reply contains "accept"
And reply contains "sess01"
And session "sess01" has permission mode "acceptEdits"
Scenario: Set active session to plan mode
Given user has session "sess01" in "/tmp/proj1"
And active session is "sess01"
When user sends "/perm plan"
Then reply contains "plan"
And session "sess01" has permission mode "plan"
Scenario: Set active session back to bypass
Given user has session "sess01" in "/tmp/proj1"
And active session is "sess01"
When user sends "/perm bypass"
Then reply contains "bypass"
And session "sess01" has permission mode "bypassPermissions"
Scenario: Unknown mode returns error
Given user has session "sess01" in "/tmp/proj1"
And active session is "sess01"
When user sends "/perm turbo"
Then reply contains "Unknown mode"
Scenario: No active session returns error
Given no active session for user "user_abc123"
When user sends "/perm accept"
Then reply contains "No active session"
Scenario: Set permission on specific conv_id
Given user has session "sess01" in "/tmp/proj1"
And user has session "sess02" in "/tmp/proj2"
And active session is "sess01"
When user sends "/perm plan sess02"
Then reply contains "sess02"
And session "sess02" has permission mode "plan"
Scenario: Cannot change permission of another user's session
Given session "sess01" in "/tmp/proj1" belongs to user "other_user"
When user sends "/perm accept sess01"
Then reply contains "another user"
Scenario: New session with --perm accept
When user sends "/new myproject --perm accept"
Then reply contains "accept"
And session manager has 1 session for user "user_abc123"
Scenario: New session with --perm plan
When user sends "/new myproject --perm plan"
Then reply contains "plan"
Scenario: New session with invalid --perm
When user sends "/new myproject --perm turbo"
Then reply contains "Invalid"

View File

@ -143,6 +143,15 @@ def check_no_active_session(user_id):
assert agent._active_conv.get(user_id) is None assert agent._active_conv.get(user_id) is None
@then(parsers.parse('session "{conv_id}" has permission mode "{mode}"'))
def check_session_perm_mode(conv_id, mode):
from agent.manager import manager
session = manager._sessions.get(conv_id)
assert session is not None, f"Session {conv_id!r} not found"
assert session.permission_mode == mode, \
f"Expected permission_mode={mode!r}, got {session.permission_mode!r}"
# ── Then: mode state ───────────────────────────────────────────────────────── # ── Then: mode state ─────────────────────────────────────────────────────────
@then(parsers.parse('passthrough mode is enabled for user "{user_id}"')) @then(parsers.parse('passthrough mode is enabled for user "{user_id}"'))

View File

@ -20,6 +20,7 @@ scenarios(
"../features/commands/tasks.feature", "../features/commands/tasks.feature",
"../features/commands/nodes.feature", "../features/commands/nodes.feature",
"../features/commands/help.feature", "../features/commands/help.feature",
"../features/commands/perm.feature",
) )