Compare commits
3 Commits
6cf2143987
...
d6183594d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6183594d6 | ||
|
|
cbeafa35a5 | ||
|
|
8dab229aaf |
@ -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()}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
154
bot/commands.py
154
bot/commands.py
@ -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.")
|
||||||
@ -161,6 +198,28 @@ async def _cmd_status(user_id: str) -> str:
|
|||||||
|
|
||||||
async def _cmd_close(user_id: str, args: str) -> str:
|
async def _cmd_close(user_id: str, args: str) -> str:
|
||||||
"""Close a session."""
|
"""Close a session."""
|
||||||
|
# If a specific conv_id is given by name (not a number), resolve it directly.
|
||||||
|
if args:
|
||||||
|
try:
|
||||||
|
int(args)
|
||||||
|
by_number = True
|
||||||
|
except ValueError:
|
||||||
|
by_number = False
|
||||||
|
|
||||||
|
if not by_number:
|
||||||
|
# Explicit conv_id given — look it up directly (may belong to another user).
|
||||||
|
conv_id = args.strip()
|
||||||
|
try:
|
||||||
|
success = await manager.close(conv_id, user_id=user_id)
|
||||||
|
if success:
|
||||||
|
if agent.get_active_conv(user_id) == conv_id:
|
||||||
|
agent._active_conv[user_id] = None
|
||||||
|
return f"✓ Closed session `{conv_id}`"
|
||||||
|
else:
|
||||||
|
return f"Session `{conv_id}` not found."
|
||||||
|
except PermissionError as e:
|
||||||
|
return str(e)
|
||||||
|
|
||||||
sessions = manager.list_sessions(user_id=user_id)
|
sessions = manager.list_sessions(user_id=user_id)
|
||||||
if not sessions:
|
if not sessions:
|
||||||
return "No sessions to close."
|
return "No sessions to close."
|
||||||
@ -212,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."
|
||||||
@ -335,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)
|
||||||
@ -347,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 的操作计划再决定是否执行"""
|
||||||
|
|||||||
@ -25,6 +25,26 @@ _ws_connected: bool = False
|
|||||||
_last_message_time: float = 0.0
|
_last_message_time: float = 0.0
|
||||||
_reconnect_count: int = 0
|
_reconnect_count: int = 0
|
||||||
|
|
||||||
|
# Deduplication: drop Feishu re-deliveries by (user_id, content) within a short window.
|
||||||
|
# Feishu retries on network hiccups within ~60s using the same payload.
|
||||||
|
# We use a 10s window: identical content from the same user within 10s is a re-delivery,
|
||||||
|
# not a deliberate repeat (user intentional repeats arrive after the bot has already replied).
|
||||||
|
_recent_messages: dict[tuple[str, str], float] = {} # key: (user_id, content) → timestamp
|
||||||
|
_DEDUP_WINDOW = 10.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _is_duplicate(user_id: str, content: str) -> bool:
|
||||||
|
"""Return True if this (user, content) pair arrived within the dedup window."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, ts in _recent_messages.items() if now - ts > _DEDUP_WINDOW]
|
||||||
|
for k in expired:
|
||||||
|
del _recent_messages[k]
|
||||||
|
key = (user_id, content)
|
||||||
|
if key in _recent_messages:
|
||||||
|
return True
|
||||||
|
_recent_messages[key] = now
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_ws_status() -> dict[str, Any]:
|
def get_ws_status() -> dict[str, Any]:
|
||||||
"""Return WebSocket connection status."""
|
"""Return WebSocket connection status."""
|
||||||
@ -79,10 +99,14 @@ def _handle_message(data: P2ImMessageReceiveV1) -> None:
|
|||||||
logger.info("Empty text after stripping, ignoring")
|
logger.info("Empty text after stripping, ignoring")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("✉ ...%s → %r", open_id[-8:], text[:80])
|
|
||||||
|
|
||||||
user_id = open_id or chat_id
|
user_id = open_id or chat_id
|
||||||
|
|
||||||
|
if _is_duplicate(user_id, text):
|
||||||
|
logger.info("Dropping duplicate delivery: user=...%s text=%r", user_id[-8:], text[:60])
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("✉ ...%s → %r", open_id[-8:], text[:80])
|
||||||
|
|
||||||
if _main_loop is None:
|
if _main_loop is None:
|
||||||
logger.error("Main event loop not set; cannot process message")
|
logger.error("Main event loop not set; cannot process message")
|
||||||
return
|
return
|
||||||
|
|||||||
16
conftest.py
Normal file
16
conftest.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Root conftest — runs before pytest collects any test files or imports any
|
||||||
|
production modules. Patches config._CONFIG_PATH to point at the test keyring
|
||||||
|
so that `import config` never tries to open the real keyring.yaml.
|
||||||
|
|
||||||
|
Must live at the repo root (not inside tests/) to fire before collection.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
_TEST_KEYRING = Path(__file__).parent / "tests" / "keyring_test.yaml"
|
||||||
|
|
||||||
|
# Patch config before anything else imports it
|
||||||
|
import config as _config_mod
|
||||||
|
_config_mod._CONFIG_PATH = _TEST_KEYRING
|
||||||
|
importlib.reload(_config_mod)
|
||||||
@ -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).
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
5
requirements-dev.txt
Normal file
5
requirements-dev.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.24.0
|
||||||
|
pytest-bdd>=7.0.0
|
||||||
|
pytest-recording>=0.13.0
|
||||||
|
pytest-mock>=3.12.0
|
||||||
167
tests/conftest.py
Normal file
167
tests/conftest.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Master test fixtures for PhoneWork BDD tests.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
TESTS_DIR = Path(__file__).parent
|
||||||
|
CASSETTES_DIR = TESTS_DIR / "cassettes"
|
||||||
|
CASSETTES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feishu send mock ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feishu_calls():
|
||||||
|
"""
|
||||||
|
Capture all calls to bot.feishu send functions.
|
||||||
|
Lazy imports inside commands.py pull from bot.feishu at call time,
|
||||||
|
so patching the module attributes is sufficient.
|
||||||
|
"""
|
||||||
|
captured: dict[str, list] = {"texts": [], "cards": [], "files": []}
|
||||||
|
|
||||||
|
async def mock_send_text(receive_id, receive_id_type, text):
|
||||||
|
captured["texts"].append({"receive_id": receive_id, "text": text})
|
||||||
|
|
||||||
|
async def mock_send_card(receive_id, receive_id_type, card):
|
||||||
|
captured["cards"].append({"receive_id": receive_id, "card": card})
|
||||||
|
|
||||||
|
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
|
||||||
|
captured["files"].append({"receive_id": receive_id, "file_path": file_path})
|
||||||
|
|
||||||
|
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
|
||||||
|
patch("bot.feishu.send_card", side_effect=mock_send_card), \
|
||||||
|
patch("bot.feishu.send_file", side_effect=mock_send_file):
|
||||||
|
yield captured
|
||||||
|
|
||||||
|
|
||||||
|
# ── run_claude mock ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_run_claude():
|
||||||
|
"""
|
||||||
|
Replace run_claude in both its definition and its import site in manager.py.
|
||||||
|
Default return value is a short CC-style output string.
|
||||||
|
"""
|
||||||
|
mock = AsyncMock(return_value="Claude Code: task complete.")
|
||||||
|
with patch("agent.pty_process.run_claude", mock), \
|
||||||
|
patch("agent.manager.run_claude", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton state resets ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_manager():
|
||||||
|
from agent.manager import manager
|
||||||
|
manager._sessions.clear()
|
||||||
|
yield
|
||||||
|
manager._sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_agent():
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
agent._history.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
agent._passthrough.clear()
|
||||||
|
agent._user_locks.clear()
|
||||||
|
yield
|
||||||
|
agent._history.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
agent._passthrough.clear()
|
||||||
|
agent._user_locks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_task_runner():
|
||||||
|
from agent.task_runner import task_runner
|
||||||
|
task_runner._tasks.clear()
|
||||||
|
yield
|
||||||
|
task_runner._tasks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_scheduler():
|
||||||
|
from agent.scheduler import scheduler
|
||||||
|
for task in list(getattr(scheduler, "_tasks", {}).values()):
|
||||||
|
task.cancel()
|
||||||
|
scheduler._jobs.clear()
|
||||||
|
yield
|
||||||
|
for task in list(getattr(scheduler, "_tasks", {}).values()):
|
||||||
|
task.cancel()
|
||||||
|
scheduler._jobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_contextvars():
|
||||||
|
from orchestrator.tools import set_current_user, set_current_chat
|
||||||
|
set_current_user(None)
|
||||||
|
set_current_chat(None)
|
||||||
|
yield
|
||||||
|
set_current_user(None)
|
||||||
|
set_current_chat(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_reply(pytestconfig):
|
||||||
|
"""Clear _reply before each test so stale values don't leak between scenarios."""
|
||||||
|
pytestconfig._reply = None
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ── Working directory isolation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_working_dir(tmp_path, monkeypatch):
|
||||||
|
import config
|
||||||
|
import orchestrator.tools as tools_mod
|
||||||
|
monkeypatch.setattr(config, "WORKING_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
|
||||||
|
(tmp_path / "myproject").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ── VCR cassette factory ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_vcr_cassette(cassette_name: str):
|
||||||
|
"""
|
||||||
|
Return a vcrpy context manager for the given cassette name.
|
||||||
|
Set VCR_RECORD_MODE=new_episodes locally to record; CI uses 'none'.
|
||||||
|
Authorization headers are stripped so cassettes are safe to commit.
|
||||||
|
If the cassette doesn't exist in 'none' mode, the test is skipped.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
import vcr
|
||||||
|
except ImportError:
|
||||||
|
import pytest
|
||||||
|
pytest.skip("vcrpy not installed")
|
||||||
|
|
||||||
|
record_mode = os.environ.get("VCR_RECORD_MODE", "none")
|
||||||
|
cassette_path = CASSETTES_DIR / cassette_name
|
||||||
|
cassette_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if record_mode == "none" and not cassette_path.exists():
|
||||||
|
import pytest
|
||||||
|
pytest.skip(f"No cassette recorded yet: {cassette_name}. Run with VCR_RECORD_MODE=new_episodes to record.")
|
||||||
|
|
||||||
|
my_vcr = vcr.VCR(
|
||||||
|
record_mode=record_mode,
|
||||||
|
match_on=["method", "scheme", "host", "port", "path", "body"],
|
||||||
|
filter_headers=["authorization", "x-api-key"],
|
||||||
|
decode_compressed_response=True,
|
||||||
|
)
|
||||||
|
return my_vcr.use_cassette(str(cassette_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vcr_cassette():
|
||||||
|
return make_vcr_cassette
|
||||||
19
tests/features/agent/passthrough.feature
Normal file
19
tests/features/agent/passthrough.feature
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Feature: Direct (passthrough) mode — bypass LLM for CC sessions
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
And run_claude returns "Done. Here is the result."
|
||||||
|
|
||||||
|
Scenario: Passthrough sends directly to CC without LLM
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
And direct mode is enabled for user "user_abc123"
|
||||||
|
When user sends agent message "run the tests"
|
||||||
|
Then run_claude was called
|
||||||
|
And reply contains "Done. Here is the result."
|
||||||
|
|
||||||
|
Scenario: Passthrough on missing session clears active conv
|
||||||
|
Given active session is "ghost_session_id" which does not exist
|
||||||
|
And direct mode is enabled for user "user_abc123"
|
||||||
|
When user sends agent message "hello"
|
||||||
|
Then active session for user "user_abc123" is None
|
||||||
35
tests/features/agent/routing.feature
Normal file
35
tests/features/agent/routing.feature
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
Feature: LLM smart routing — agent routes messages to correct tools
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is in smart mode
|
||||||
|
And run_claude returns "I created the component for you."
|
||||||
|
|
||||||
|
@vcr
|
||||||
|
Scenario: Agent creates new session for project task
|
||||||
|
Given vcr cassette "agent/routing_new_session.yaml"
|
||||||
|
When user sends agent message "create a React app in todo_app folder"
|
||||||
|
Then agent created a session for user "user_abc123"
|
||||||
|
And reply is not empty
|
||||||
|
|
||||||
|
@vcr
|
||||||
|
Scenario: Agent answers general question without creating session
|
||||||
|
Given vcr cassette "agent/routing_general_qa.yaml"
|
||||||
|
When user sends agent message "what is a Python generator?"
|
||||||
|
Then no session is created for user "user_abc123"
|
||||||
|
And reply is not empty
|
||||||
|
|
||||||
|
@vcr
|
||||||
|
Scenario: Agent sends follow-up to existing session
|
||||||
|
Given user has active session "sess01" in "/tmp/proj1"
|
||||||
|
And vcr cassette "agent/routing_follow_up.yaml"
|
||||||
|
When user sends agent message "now add tests for that"
|
||||||
|
Then run_claude was called
|
||||||
|
And reply is not empty
|
||||||
|
|
||||||
|
@vcr
|
||||||
|
Scenario: Agent answers direct QA without tools when no active session
|
||||||
|
Given no active session for user "user_abc123"
|
||||||
|
And vcr cassette "agent/routing_direct_qa.yaml"
|
||||||
|
When user sends agent message "explain async/await in Python"
|
||||||
|
Then reply is not empty
|
||||||
|
And reply does not contain "Max iterations"
|
||||||
38
tests/features/commands/close.feature
Normal file
38
tests/features/commands/close.feature
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
Feature: /close command — terminate a session
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No sessions returns error
|
||||||
|
When user sends "/close"
|
||||||
|
Then reply contains "No sessions to close"
|
||||||
|
|
||||||
|
Scenario: Close active session by default
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
When user sends "/close"
|
||||||
|
Then reply contains "Closed session"
|
||||||
|
And session manager has 0 sessions for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: Close session by number
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And user has session "sess02" in "/tmp/proj2"
|
||||||
|
When user sends "/close 1"
|
||||||
|
Then reply contains "Closed session"
|
||||||
|
And session manager has 1 session for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: Invalid number returns error
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
When user sends "/close 9"
|
||||||
|
Then reply contains "Invalid session number"
|
||||||
|
|
||||||
|
Scenario: Cannot close another user's session
|
||||||
|
Given session "sess01" in "/tmp/proj1" belongs to user "other_user"
|
||||||
|
When user sends "/close sess01"
|
||||||
|
Then reply contains "belongs to another user"
|
||||||
|
|
||||||
|
Scenario: Closing active session clears active conv
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
When user sends "/close"
|
||||||
|
Then active session for user "user_abc123" is None
|
||||||
27
tests/features/commands/direct_smart.feature
Normal file
27
tests/features/commands/direct_smart.feature
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
Feature: /direct and /smart mode toggle
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: /direct requires active session
|
||||||
|
When user sends "/direct"
|
||||||
|
Then reply contains "No active session"
|
||||||
|
|
||||||
|
Scenario: /direct enables passthrough mode
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
When user sends "/direct"
|
||||||
|
Then reply contains "Direct mode ON"
|
||||||
|
And passthrough mode is enabled for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: /smart disables passthrough mode
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
And direct mode is enabled for user "user_abc123"
|
||||||
|
When user sends "/smart"
|
||||||
|
Then reply contains "Smart mode ON"
|
||||||
|
And passthrough mode is disabled for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: /smart always succeeds even without active session
|
||||||
|
When user sends "/smart"
|
||||||
|
Then reply contains "Smart mode ON"
|
||||||
25
tests/features/commands/help.feature
Normal file
25
tests/features/commands/help.feature
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
Feature: /help command — show command reference
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: /help lists all commands
|
||||||
|
When user sends "/help"
|
||||||
|
Then reply contains "/new"
|
||||||
|
And reply contains "/status"
|
||||||
|
And reply contains "/close"
|
||||||
|
And reply contains "/switch"
|
||||||
|
And reply contains "/direct"
|
||||||
|
And reply contains "/smart"
|
||||||
|
And reply contains "/shell"
|
||||||
|
And reply contains "/remind"
|
||||||
|
And reply contains "/tasks"
|
||||||
|
And reply contains "/nodes"
|
||||||
|
|
||||||
|
Scenario: /h alias works
|
||||||
|
When user sends "/h"
|
||||||
|
Then reply contains "/new"
|
||||||
|
|
||||||
|
Scenario: Unknown command is not handled
|
||||||
|
When user sends "/unknown_xyz_cmd"
|
||||||
|
Then command is not handled
|
||||||
37
tests/features/commands/new.feature
Normal file
37
tests/features/commands/new.feature
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
Feature: /new command — create a Claude Code session
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No arguments shows usage
|
||||||
|
When user sends "/new"
|
||||||
|
Then reply contains "Usage: /new"
|
||||||
|
|
||||||
|
Scenario: Creates session with valid directory
|
||||||
|
Given run_claude returns "Session ready."
|
||||||
|
When user sends "/new myproject"
|
||||||
|
Then reply contains "myproject"
|
||||||
|
And session manager has 1 session for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: Creates session with initial message
|
||||||
|
Given run_claude returns "Fixed the bug."
|
||||||
|
When user sends "/new myproject fix the login bug"
|
||||||
|
Then reply contains "myproject"
|
||||||
|
|
||||||
|
Scenario: Path traversal attempt is blocked
|
||||||
|
When user sends "/new ../../etc"
|
||||||
|
Then reply contains "Error"
|
||||||
|
And session manager has 0 sessions for user "user_abc123"
|
||||||
|
|
||||||
|
Scenario: Custom timeout is accepted
|
||||||
|
Given run_claude returns "Done."
|
||||||
|
When user sends "/new myproject --timeout 60"
|
||||||
|
Then reply contains "myproject"
|
||||||
|
And reply contains "timeout: 60s"
|
||||||
|
|
||||||
|
Scenario: Creates session and sends card when chat_id is set
|
||||||
|
Given the current chat_id is "chat_xyz"
|
||||||
|
And run_claude returns "Ready."
|
||||||
|
When user sends "/new myproject"
|
||||||
|
Then a sessions card is sent to chat "chat_xyz"
|
||||||
|
And text reply is empty
|
||||||
13
tests/features/commands/nodes.feature
Normal file
13
tests/features/commands/nodes.feature
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Feature: /nodes and /node commands — multi-host node management
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
And ROUTER_MODE is disabled
|
||||||
|
|
||||||
|
Scenario: /nodes outside router mode returns explanation
|
||||||
|
When user sends "/nodes"
|
||||||
|
Then reply contains "Not in router mode"
|
||||||
|
|
||||||
|
Scenario: /node outside router mode returns explanation
|
||||||
|
When user sends "/node myhost"
|
||||||
|
Then reply contains "Not in router mode"
|
||||||
70
tests/features/commands/perm.feature
Normal file
70
tests/features/commands/perm.feature
Normal 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"
|
||||||
33
tests/features/commands/remind.feature
Normal file
33
tests/features/commands/remind.feature
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
Feature: /remind command — schedule a one-time reminder
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
And the current chat_id is "chat_xyz"
|
||||||
|
|
||||||
|
Scenario: No arguments shows usage
|
||||||
|
When user sends "/remind"
|
||||||
|
Then reply contains "Usage: /remind"
|
||||||
|
|
||||||
|
Scenario: Missing message part shows usage
|
||||||
|
When user sends "/remind 10m"
|
||||||
|
Then reply contains "Usage: /remind"
|
||||||
|
|
||||||
|
Scenario: Invalid time format returns error
|
||||||
|
When user sends "/remind badtime check build"
|
||||||
|
Then reply contains "Invalid time format"
|
||||||
|
|
||||||
|
Scenario: Valid reminder with seconds is scheduled
|
||||||
|
When user sends "/remind 30s check the build"
|
||||||
|
Then reply contains "Reminder #"
|
||||||
|
And reply contains "30s"
|
||||||
|
And scheduler has 1 pending job
|
||||||
|
|
||||||
|
Scenario: Valid reminder with minutes is scheduled
|
||||||
|
When user sends "/remind 5m deploy done"
|
||||||
|
Then reply contains "5m"
|
||||||
|
And scheduler has 1 pending job
|
||||||
|
|
||||||
|
Scenario: Valid reminder with hours is scheduled
|
||||||
|
When user sends "/remind 2h weekly report"
|
||||||
|
Then reply contains "2h"
|
||||||
|
And scheduler has 1 pending job
|
||||||
22
tests/features/commands/shell.feature
Normal file
22
tests/features/commands/shell.feature
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
Feature: /shell command — run host shell commands
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No arguments shows usage
|
||||||
|
When user sends "/shell"
|
||||||
|
Then reply contains "Usage: /shell"
|
||||||
|
|
||||||
|
Scenario: Runs echo and returns output
|
||||||
|
When user sends "/shell echo hello"
|
||||||
|
Then reply contains "hello"
|
||||||
|
And reply contains "exit code: 0"
|
||||||
|
|
||||||
|
Scenario: Blocked dangerous command is rejected
|
||||||
|
When user sends "/shell rm -rf /"
|
||||||
|
Then reply contains "Blocked"
|
||||||
|
And reply does not contain "exit code"
|
||||||
|
|
||||||
|
Scenario: Non-zero exit code is reported
|
||||||
|
When user sends "/shell exit 1"
|
||||||
|
Then reply contains "exit code"
|
||||||
40
tests/features/commands/status.feature
Normal file
40
tests/features/commands/status.feature
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
Feature: /status command — list sessions and current mode
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No sessions returns empty message
|
||||||
|
When user sends "/status"
|
||||||
|
Then reply contains "No active sessions"
|
||||||
|
|
||||||
|
Scenario: Shows session list
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And user has session "sess02" in "/tmp/proj2"
|
||||||
|
When user sends "/status"
|
||||||
|
Then reply contains "sess01"
|
||||||
|
And reply contains "sess02"
|
||||||
|
|
||||||
|
Scenario: Shows active marker on current session
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
When user sends "/status"
|
||||||
|
Then reply contains "→"
|
||||||
|
|
||||||
|
Scenario: Shows current mode as Smart by default
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
When user sends "/status"
|
||||||
|
Then reply contains "Smart"
|
||||||
|
|
||||||
|
Scenario: Shows Direct mode after /direct
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And active session is "sess01"
|
||||||
|
And direct mode is enabled for user "user_abc123"
|
||||||
|
When user sends "/status"
|
||||||
|
Then reply contains "Direct"
|
||||||
|
|
||||||
|
Scenario: Sends card when chat_id is set
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And the current chat_id is "chat_xyz"
|
||||||
|
When user sends "/status"
|
||||||
|
Then a sessions card is sent to chat "chat_xyz"
|
||||||
|
And text reply is empty
|
||||||
30
tests/features/commands/switch.feature
Normal file
30
tests/features/commands/switch.feature
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
Feature: /switch command — activate a different session
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No sessions returns error
|
||||||
|
When user sends "/switch 1"
|
||||||
|
Then reply contains "No sessions available"
|
||||||
|
|
||||||
|
Scenario: Valid switch updates active session
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
And user has session "sess02" in "/tmp/proj2"
|
||||||
|
When user sends "/switch 2"
|
||||||
|
Then reply contains "Switched to session"
|
||||||
|
And active session for user "user_abc123" is "sess02"
|
||||||
|
|
||||||
|
Scenario: Out of range number returns error
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
When user sends "/switch 5"
|
||||||
|
Then reply contains "Invalid session number"
|
||||||
|
|
||||||
|
Scenario: Non-numeric argument returns error
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
When user sends "/switch notanumber"
|
||||||
|
Then reply contains "Invalid number"
|
||||||
|
|
||||||
|
Scenario: Missing argument shows usage
|
||||||
|
Given user has session "sess01" in "/tmp/proj1"
|
||||||
|
When user sends "/switch"
|
||||||
|
Then reply contains "Usage: /switch"
|
||||||
27
tests/features/commands/tasks.feature
Normal file
27
tests/features/commands/tasks.feature
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
Feature: /tasks command — list background tasks
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given user "user_abc123" is sending commands
|
||||||
|
|
||||||
|
Scenario: No tasks returns empty message
|
||||||
|
When user sends "/tasks"
|
||||||
|
Then reply contains "No background tasks"
|
||||||
|
|
||||||
|
Scenario: Shows running task with spinner emoji
|
||||||
|
Given there is a running task "task001" described as "CC session abc: fix bug"
|
||||||
|
When user sends "/tasks"
|
||||||
|
Then reply contains "task001"
|
||||||
|
And reply contains "fix bug"
|
||||||
|
And reply contains "⏳"
|
||||||
|
|
||||||
|
Scenario: Shows completed task with checkmark
|
||||||
|
Given there is a completed task "task002" described as "CC session xyz: deploy"
|
||||||
|
When user sends "/tasks"
|
||||||
|
Then reply contains "task002"
|
||||||
|
And reply contains "✅"
|
||||||
|
|
||||||
|
Scenario: Shows failed task with cross
|
||||||
|
Given there is a failed task "task003" described as "CC session err: bad cmd"
|
||||||
|
When user sends "/tasks"
|
||||||
|
Then reply contains "task003"
|
||||||
|
And reply contains "❌"
|
||||||
10
tests/keyring_test.yaml
Normal file
10
tests/keyring_test.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
FEISHU_APP_ID: test_app_id
|
||||||
|
FEISHU_APP_SECRET: test_app_secret
|
||||||
|
OPENAI_BASE_URL: https://open.bigmodel.cn/api/paas/v4/
|
||||||
|
OPENAI_API_KEY: test_api_key_for_vcr
|
||||||
|
OPENAI_MODEL: glm-4.7
|
||||||
|
WORKING_DIR: /tmp/phonework_test
|
||||||
|
METASO_API_KEY: ""
|
||||||
|
ROUTER_MODE: false
|
||||||
|
ROUTER_SECRET: ""
|
||||||
|
ALLOWED_OPEN_IDS: []
|
||||||
0
tests/step_defs/__init__.py
Normal file
0
tests/step_defs/__init__.py
Normal file
192
tests/step_defs/common_steps.py
Normal file
192
tests/step_defs/common_steps.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Shared Given/Then step definitions used across all feature files.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytest_bdd import given, then, parsers
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: user identity ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('user "{user_id}" is sending commands'))
|
||||||
|
def set_user(user_id, pytestconfig):
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
set_current_user(user_id)
|
||||||
|
pytestconfig._test_user_id = user_id
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('the current chat_id is "{chat_id}"'))
|
||||||
|
def set_chat(chat_id):
|
||||||
|
from orchestrator.tools import set_current_chat
|
||||||
|
set_current_chat(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: session setup ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('user has session "{conv_id}" in "{cwd}"'))
|
||||||
|
def add_session(conv_id, cwd, pytestconfig, tmp_path):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=user_id, cc_timeout=50.0)
|
||||||
|
(tmp_path / conv_id).mkdir(exist_ok=True)
|
||||||
|
manager._sessions[conv_id] = session
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('session "{conv_id}" in "{cwd}" belongs to user "{owner}"'))
|
||||||
|
def add_foreign_session(conv_id, cwd, owner, tmp_path):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=owner, cc_timeout=50.0)
|
||||||
|
(tmp_path / conv_id).mkdir(exist_ok=True)
|
||||||
|
manager._sessions[conv_id] = session
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('active session is "{conv_id}"'))
|
||||||
|
def set_active_session(conv_id, pytestconfig):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
agent._active_conv[user_id] = conv_id
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('active session is "{conv_id}" which does not exist'))
|
||||||
|
def set_ghost_active_session(conv_id, pytestconfig):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
agent._active_conv[user_id] = conv_id
|
||||||
|
# intentionally NOT added to manager._sessions
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('no active session for user "{user_id}"'))
|
||||||
|
def ensure_no_active_session(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
agent._active_conv[user_id] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: mode toggles ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('direct mode is enabled for user "{user_id}"'))
|
||||||
|
def enable_direct_mode(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
agent._passthrough[user_id] = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: mocks ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('run_claude returns "{output}"'))
|
||||||
|
def set_run_claude_return(output, mock_run_claude):
|
||||||
|
mock_run_claude.return_value = output
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given("ROUTER_MODE is disabled")
|
||||||
|
def disable_router_mode(monkeypatch):
|
||||||
|
import config
|
||||||
|
monkeypatch.setattr(config, "ROUTER_MODE", False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: reply assertions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@then(parsers.parse('reply contains "{text}"'))
|
||||||
|
def reply_contains(text, pytestconfig):
|
||||||
|
reply = getattr(pytestconfig, "_reply", None)
|
||||||
|
assert text in (reply or ""), \
|
||||||
|
f"Expected {text!r} in reply, got: {reply!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@then(parsers.parse('reply does not contain "{text}"'))
|
||||||
|
def reply_not_contains(text, pytestconfig):
|
||||||
|
reply = getattr(pytestconfig, "_reply", None)
|
||||||
|
assert text not in (reply or ""), \
|
||||||
|
f"Expected {text!r} NOT in reply, got: {reply!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@then("reply is not empty")
|
||||||
|
def reply_not_empty(pytestconfig):
|
||||||
|
reply = getattr(pytestconfig, "_reply", None)
|
||||||
|
assert reply and reply.strip(), \
|
||||||
|
f"Expected non-empty reply, got: {reply!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@then("text reply is empty")
|
||||||
|
def reply_is_empty(pytestconfig):
|
||||||
|
reply = getattr(pytestconfig, "_reply", None)
|
||||||
|
assert reply == "", \
|
||||||
|
f"Expected empty reply, got: {reply!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@then("command is not handled")
|
||||||
|
def command_not_handled(pytestconfig):
|
||||||
|
reply = getattr(pytestconfig, "_reply", None)
|
||||||
|
assert reply is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: session state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@then(parsers.parse('session manager has {count:d} session for user "{user_id}"'))
|
||||||
|
@then(parsers.parse('session manager has {count:d} sessions for user "{user_id}"'))
|
||||||
|
def check_session_count(count, user_id):
|
||||||
|
from agent.manager import manager
|
||||||
|
sessions = manager.list_sessions(user_id=user_id)
|
||||||
|
assert len(sessions) == count, \
|
||||||
|
f"Expected {count} sessions, got {len(sessions)}: {sessions}"
|
||||||
|
|
||||||
|
|
||||||
|
@then(parsers.parse('active session for user "{user_id}" is "{conv_id}"'))
|
||||||
|
def check_active_session(user_id, conv_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
assert agent._active_conv.get(user_id) == conv_id
|
||||||
|
|
||||||
|
|
||||||
|
@then(parsers.parse('active session for user "{user_id}" is None'))
|
||||||
|
def check_no_active_session(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
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(parsers.parse('passthrough mode is enabled for user "{user_id}"'))
|
||||||
|
def check_passthrough_on(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
assert agent._passthrough.get(user_id) is True
|
||||||
|
|
||||||
|
|
||||||
|
@then(parsers.parse('passthrough mode is disabled for user "{user_id}"'))
|
||||||
|
def check_passthrough_off(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
assert agent._passthrough.get(user_id) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: Feishu output ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@then(parsers.parse('a sessions card is sent to chat "{chat_id}"'))
|
||||||
|
def check_card_sent(chat_id, feishu_calls):
|
||||||
|
cards = feishu_calls["cards"]
|
||||||
|
assert any(c["receive_id"] == chat_id for c in cards), \
|
||||||
|
f"No card sent to {chat_id!r}, captured: {cards}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: scheduler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@then(parsers.parse('scheduler has {count:d} pending job'))
|
||||||
|
@then(parsers.parse('scheduler has {count:d} pending jobs'))
|
||||||
|
def check_scheduler_jobs(count):
|
||||||
|
from agent.scheduler import scheduler
|
||||||
|
assert len(scheduler._jobs) == count, \
|
||||||
|
f"Expected {count} jobs, got {len(scheduler._jobs)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: run_claude ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@then("run_claude was called")
|
||||||
|
def check_run_claude_called(mock_run_claude):
|
||||||
|
assert mock_run_claude.call_count >= 1, "Expected run_claude to be called"
|
||||||
76
tests/step_defs/test_agent.py
Normal file
76
tests/step_defs/test_agent.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Step definitions for agent routing and passthrough features.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytest_bdd import scenarios, given, when, then, parsers
|
||||||
|
|
||||||
|
from tests.step_defs.common_steps import * # noqa: F401,F403 — import shared steps
|
||||||
|
|
||||||
|
scenarios(
|
||||||
|
"../features/agent/routing.feature",
|
||||||
|
"../features/agent/passthrough.feature",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: agent-specific setup ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('user "{user_id}" is in smart mode'))
|
||||||
|
def set_smart_mode(user_id, pytestconfig):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
set_current_user(user_id)
|
||||||
|
agent._passthrough[user_id] = False
|
||||||
|
pytestconfig._test_user_id = user_id
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('user has active session "{conv_id}" in "{cwd}"'))
|
||||||
|
def add_and_activate_session(conv_id, cwd, pytestconfig, tmp_path):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
session = Session(conv_id=conv_id, cwd=str(tmp_path / conv_id), owner_id=user_id, cc_timeout=50.0)
|
||||||
|
(tmp_path / conv_id).mkdir(exist_ok=True)
|
||||||
|
manager._sessions[conv_id] = session
|
||||||
|
agent._active_conv[user_id] = conv_id
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('vcr cassette "{cassette_name}"'))
|
||||||
|
def set_vcr_cassette(cassette_name, pytestconfig):
|
||||||
|
pytestconfig._vcr_cassette = cassette_name
|
||||||
|
|
||||||
|
|
||||||
|
# ── When: send message through agent ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@when(parsers.parse('user sends agent message "{text}"'))
|
||||||
|
def send_agent_message(text, pytestconfig, mock_run_claude, feishu_calls):
|
||||||
|
import asyncio
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from tests.conftest import make_vcr_cassette
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
cassette_name = getattr(pytestconfig, "_vcr_cassette", None)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
if cassette_name:
|
||||||
|
with make_vcr_cassette(cassette_name):
|
||||||
|
reply = loop.run_until_complete(agent.run(user_id, text))
|
||||||
|
else:
|
||||||
|
reply = loop.run_until_complete(agent.run(user_id, text))
|
||||||
|
|
||||||
|
pytestconfig._reply = reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── Then: agent-specific assertions ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@then(parsers.parse('agent created a session for user "{user_id}"'))
|
||||||
|
def check_session_created(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
assert agent._active_conv.get(user_id) is not None, \
|
||||||
|
f"Expected active session to be set for {user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@then(parsers.parse('no session is created for user "{user_id}"'))
|
||||||
|
def check_no_session(user_id):
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
assert agent._active_conv.get(user_id) is None, \
|
||||||
|
f"Expected no active session for {user_id}, got {agent._active_conv.get(user_id)}"
|
||||||
79
tests/step_defs/test_commands.py
Normal file
79
tests/step_defs/test_commands.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Step definitions for all slash command features.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pytest_bdd import scenarios, given, when, then, parsers
|
||||||
|
|
||||||
|
from tests.step_defs.common_steps import * # noqa: F401,F403 — import shared steps
|
||||||
|
|
||||||
|
scenarios(
|
||||||
|
"../features/commands/new.feature",
|
||||||
|
"../features/commands/status.feature",
|
||||||
|
"../features/commands/switch.feature",
|
||||||
|
"../features/commands/close.feature",
|
||||||
|
"../features/commands/direct_smart.feature",
|
||||||
|
"../features/commands/shell.feature",
|
||||||
|
"../features/commands/remind.feature",
|
||||||
|
"../features/commands/tasks.feature",
|
||||||
|
"../features/commands/nodes.feature",
|
||||||
|
"../features/commands/help.feature",
|
||||||
|
"../features/commands/perm.feature",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── When: send slash command ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@when(parsers.parse('user sends "{text}"'))
|
||||||
|
def send_command(text, pytestconfig, feishu_calls, mock_run_claude):
|
||||||
|
import asyncio
|
||||||
|
from bot.commands import handle_command
|
||||||
|
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
||||||
|
reply = asyncio.get_event_loop().run_until_complete(handle_command(user_id, text))
|
||||||
|
pytestconfig._reply = reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── Given: task runner state ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@given(parsers.parse('there is a running task "{task_id}" described as "{desc}"'))
|
||||||
|
def add_running_task(task_id, desc):
|
||||||
|
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
||||||
|
task = BackgroundTask(
|
||||||
|
task_id=task_id,
|
||||||
|
description=desc,
|
||||||
|
started_at=time.time(),
|
||||||
|
status=TaskStatus.RUNNING,
|
||||||
|
)
|
||||||
|
task_runner._tasks[task_id] = task
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('there is a completed task "{task_id}" described as "{desc}"'))
|
||||||
|
def add_completed_task(task_id, desc):
|
||||||
|
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
||||||
|
now = time.time()
|
||||||
|
task = BackgroundTask(
|
||||||
|
task_id=task_id,
|
||||||
|
description=desc,
|
||||||
|
started_at=now - 5,
|
||||||
|
status=TaskStatus.COMPLETED,
|
||||||
|
completed_at=now,
|
||||||
|
result="success",
|
||||||
|
)
|
||||||
|
task_runner._tasks[task_id] = task
|
||||||
|
|
||||||
|
|
||||||
|
@given(parsers.parse('there is a failed task "{task_id}" described as "{desc}"'))
|
||||||
|
def add_failed_task(task_id, desc):
|
||||||
|
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
||||||
|
now = time.time()
|
||||||
|
task = BackgroundTask(
|
||||||
|
task_id=task_id,
|
||||||
|
description=desc,
|
||||||
|
started_at=now - 3,
|
||||||
|
status=TaskStatus.FAILED,
|
||||||
|
completed_at=now,
|
||||||
|
error="subprocess failed",
|
||||||
|
)
|
||||||
|
task_runner._tasks[task_id] = task
|
||||||
Loading…
x
Reference in New Issue
Block a user