From eac90941efb5a7671a2dfd1a07b64b0654facfc8 Mon Sep 17 00:00:00 2001 From: Yuyao Huang Date: Wed, 1 Apr 2026 12:51:00 +0800 Subject: [PATCH] feat: add SDK session implementation with approval flow and audit logging - Implement SDK session with secretary model for tool approval flow - Add audit logging for tool usage and permission decisions - Support Feishu card interactions for approval requests - Add new commands for task interruption and progress checking - Remove old test files and update documentation --- CLAUDE.md | 3 +- agent/audit.py | 55 ++ agent/manager.py | 198 ++--- agent/sdk_hooks.py | 72 ++ agent/sdk_session.py | 355 ++++++++ bot/commands.py | 69 +- bot/feishu.py | 46 +- bot/handler.py | 55 ++ config.py | 4 + conftest.py | 18 +- docs/feishu/card_callback.md | 72 ++ docs/feishu/card_callback_communication.md | 253 ++++++ orchestrator/agent.py | 16 +- orchestrator/tools.py | 70 +- tests/conftest.py | 91 +-- tests/features/agent/passthrough.feature | 19 - tests/features/agent/routing.feature | 35 - tests/features/commands/close.feature | 38 - tests/features/commands/direct_smart.feature | 27 - tests/features/commands/help.feature | 25 - tests/features/commands/new.feature | 37 - tests/features/commands/nodes.feature | 13 - tests/features/commands/perm.feature | 70 -- tests/features/commands/remind.feature | 33 - tests/features/commands/shell.feature | 22 - tests/features/commands/status.feature | 40 - tests/features/commands/switch.feature | 30 - tests/features/commands/tasks.feature | 27 - tests/step_defs/__init__.py | 0 tests/step_defs/common_steps.py | 192 ----- tests/step_defs/test_agent.py | 76 -- tests/step_defs/test_commands.py | 79 -- tests/test_commands.py | 384 +++++++++ tests/test_sdk_migration.py | 816 +++++++++++++++++++ 34 files changed, 2375 insertions(+), 965 deletions(-) create mode 100644 agent/sdk_hooks.py create mode 100644 agent/sdk_session.py create mode 100644 docs/feishu/card_callback.md create mode 100644 docs/feishu/card_callback_communication.md delete mode 100644 tests/features/agent/passthrough.feature delete mode 100644 tests/features/agent/routing.feature delete mode 100644 tests/features/commands/close.feature delete mode 100644 tests/features/commands/direct_smart.feature delete mode 100644 tests/features/commands/help.feature delete mode 100644 tests/features/commands/new.feature delete mode 100644 tests/features/commands/nodes.feature delete mode 100644 tests/features/commands/perm.feature delete mode 100644 tests/features/commands/remind.feature delete mode 100644 tests/features/commands/shell.feature delete mode 100644 tests/features/commands/status.feature delete mode 100644 tests/features/commands/switch.feature delete mode 100644 tests/features/commands/tasks.feature delete mode 100644 tests/step_defs/__init__.py delete mode 100644 tests/step_defs/common_steps.py delete mode 100644 tests/step_defs/test_agent.py delete mode 100644 tests/step_defs/test_commands.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_sdk_migration.py diff --git a/CLAUDE.md b/CLAUDE.md index 3566beb..02bb435 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ | Project architecture, deployment, bot commands | `README.md` | | Claude Agent SDK usage patterns and tested examples | `docs/claude/` | | SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` | +| SDK session implementation (secretary model) | `agent/sdk_session.py` | | Feishu card / markdown formatting | `docs/feishu/` | ## Claude Agent SDK @@ -30,7 +31,7 @@ When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**: ``` bot/ Feishu event handling, commands, message sending orchestrator/ LangChain agent + tools (session management, shell, files, web) -agent/ Claude Code runner, session manager, task runner, scheduler, audit +agent/ SDK session (secretary model), session manager, hooks, audit router/ Multi-host routing (public VPS side) host_client/ Host client (behind NAT, connects to router) shared/ Wire protocol for router ↔ host communication diff --git a/agent/audit.py b/agent/audit.py index 37c5609..967a09a 100644 --- a/agent/audit.py +++ b/agent/audit.py @@ -58,6 +58,61 @@ def log_interaction( logger.exception("Failed to log audit entry for session %s", conv_id) +def log_tool_use( + session_id: str, + tool_name: str, + tool_input: dict, + tool_response: Optional[object] = None, +) -> None: + """Log a tool call to the audit JSONL file.""" + try: + _ensure_audit_dir() + log_file = AUDIT_DIR / f"{session_id}.jsonl" + + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "type": "tool_use", + "session_id": session_id, + "tool_name": tool_name, + "tool_input": str(tool_input)[:500], + } + if tool_response is not None: + entry["tool_response"] = str(tool_response)[:500] + + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + except Exception: + logger.exception("Failed to log tool use for session %s", session_id) + + +def log_permission_decision( + conv_id: str, + tool_name: str, + tool_input: dict, + approved: bool, +) -> None: + """Log a permission approval/denial decision.""" + try: + _ensure_audit_dir() + log_file = AUDIT_DIR / f"{conv_id}.jsonl" + + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "type": "permission_decision", + "conv_id": conv_id, + "tool_name": tool_name, + "tool_input": str(tool_input)[:300], + "approved": approved, + } + + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + except Exception: + logger.exception("Failed to log permission decision for session %s", conv_id) + + def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]: """Read the audit log for a session.""" log_file = AUDIT_DIR / f"{conv_id}.jsonl" diff --git a/agent/manager.py b/agent/manager.py index 79101e2..0ff4ab2 100644 --- a/agent/manager.py +++ b/agent/manager.py @@ -5,18 +5,20 @@ from __future__ import annotations import asyncio import json import logging -import uuid from dataclasses import dataclass, field, asdict from pathlib import Path -from typing import Dict, List, Optional +from typing import Optional -from agent.cc_runner import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES -from agent.audit import log_interaction +from agent.sdk_session import ( + SDKSession, + SessionProgress, + DEFAULT_PERMISSION_MODE, + VALID_PERMISSION_MODES, +) logger = logging.getLogger(__name__) DEFAULT_IDLE_TIMEOUT = 30 * 60 -DEFAULT_CC_TIMEOUT = 300.0 PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json" @@ -25,21 +27,27 @@ class Session: conv_id: str cwd: str owner_id: str = "" - cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4())) last_activity: float = 0.0 - started: bool = False idle_timeout: int = DEFAULT_IDLE_TIMEOUT - cc_timeout: float = DEFAULT_CC_TIMEOUT permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE) + chat_id: str | None = None + # Runtime only — not serialized + sdk_session: SDKSession | None = field(default=None, repr=False) def touch(self) -> None: self.last_activity = asyncio.get_event_loop().time() def to_dict(self) -> dict: - return asdict(self) + d = asdict(self) + d.pop("sdk_session", None) + return d @classmethod def from_dict(cls, data: dict) -> "Session": + data.pop("sdk_session", None) + # Migration: remove old cc_runner fields if present in persisted data + for old_key in ("cc_session_id", "started", "cc_timeout"): + data.pop(old_key, None) return cls(**data) @@ -60,6 +68,9 @@ class SessionManager: if self._reaper_task: self._reaper_task.cancel() async with self._lock: + for session in self._sessions.values(): + if session.sdk_session: + await session.sdk_session.close() self._sessions.clear() if PERSISTENCE_FILE.exists(): PERSISTENCE_FILE.unlink() @@ -70,8 +81,8 @@ class SessionManager: working_dir: str, owner_id: str = "", idle_timeout: int = DEFAULT_IDLE_TIMEOUT, - cc_timeout: float = DEFAULT_CC_TIMEOUT, permission_mode: str = DEFAULT_PERMISSION_MODE, + chat_id: str | None = None, ) -> Session: async with self._lock: session = Session( @@ -79,103 +90,79 @@ class SessionManager: cwd=working_dir, owner_id=owner_id, idle_timeout=idle_timeout, - cc_timeout=cc_timeout, permission_mode=permission_mode, + chat_id=chat_id, ) self._sessions[conv_id] = session self._save() logger.info( - "Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)", + "Created session %s (owner=...%s) in %s (idle=%ds, perm=%s)", conv_id, owner_id[-8:] if owner_id else "-", working_dir, - idle_timeout, cc_timeout, permission_mode, + idle_timeout, permission_mode, ) return session - async def send(self, conv_id: str, message: str, user_id: Optional[str] = None, direct: bool = False) -> str: - async with self._lock: - 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") - session.touch() - cwd = session.cwd - cc_session_id = session.cc_session_id - cc_timeout = session.cc_timeout - permission_mode = session.permission_mode - first_message = not session.started - if first_message: - session.started = True - self._save() + # --- Secretary model: async send (returns immediately) --- - if not direct and cc_timeout > 60: - from agent.task_runner import task_runner - from orchestrator.tools import get_current_chat, set_current_chat, set_current_user + async def send_message( + self, conv_id: str, message: str, + user_id: Optional[str] = None, chat_id: Optional[str] = None, + ) -> str: + """Send a message to the session. Returns immediately; result pushed to Feishu on completion.""" + session = self._get_session(conv_id, user_id) + session.touch() + self._ensure_sdk_session(session, chat_id) + return await session.sdk_session.send(message, chat_id) - chat_id = get_current_chat() + async def send_and_wait( + self, conv_id: str, message: str, + user_id: Optional[str] = None, chat_id: Optional[str] = None, + ) -> str: + """Send and wait for completion. For LLM agent tool calls that need the result.""" + session = self._get_session(conv_id, user_id) + session.touch() + self._ensure_sdk_session(session, chat_id) + return await session.sdk_session.send_and_wait(message, chat_id) - async def run_task(): - output = await run_claude( - message, - cwd=cwd, - cc_session_id=cc_session_id, - resume=not first_message, - timeout=cc_timeout, - permission_mode=permission_mode, - ) - log_interaction( - conv_id=conv_id, - prompt=message, - response=output, - cwd=cwd, - user_id=user_id, - ) - return output + # --- Kept for backward compatibility (used by bot/commands.py _cmd_new) --- - async def on_task_complete(task) -> None: - if not chat_id or not user_id or not task.result: - return - set_current_user(user_id) - set_current_chat(chat_id) - from orchestrator.agent import agent - follow_up = ( - f"CC task completed. Output:\n{task.result}\n\n" - f"Original request was: {message}\n\n" - "If the user asked you to send a file, use send_file now. " - "Otherwise just acknowledge completion." - ) - reply = await agent.run(user_id, follow_up) - if reply: - from bot.feishu import send_text - await send_text(chat_id, "chat_id", reply) + async def send( + self, conv_id: str, message: str, + user_id: Optional[str] = None, direct: bool = False, + ) -> str: + """Backward-compatible send. Maps to send_message (async, secretary model).""" + from orchestrator.tools import get_current_chat + chat_id = get_current_chat() + return await self.send_message(conv_id, message, user_id=user_id, chat_id=chat_id) - task_id = await task_runner.submit( - run_task(), - description=f"CC session {conv_id}: {message[:50]}", - notify_chat_id=chat_id, - user_id=user_id, - on_complete=on_task_complete, - ) - return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done." + # --- Progress, interrupt, approve --- - output = await run_claude( - message, - cwd=cwd, - cc_session_id=cc_session_id, - resume=not first_message, - timeout=cc_timeout, - permission_mode=permission_mode, - ) + def get_progress(self, conv_id: str, user_id: Optional[str] = None) -> SessionProgress | None: + """Query session progress. Primary interface for the secretary AI.""" + session = self._sessions.get(conv_id) + if not session: + return None + if session.owner_id and user_id and session.owner_id != user_id: + return None + if session.sdk_session: + return session.sdk_session.get_progress() + return SessionProgress() - log_interaction( - conv_id=conv_id, - prompt=message, - response=output, - cwd=cwd, - user_id=user_id, - ) + async def interrupt(self, conv_id: str, user_id: Optional[str] = None) -> bool: + """Interrupt the currently running task in a session.""" + session = self._get_session(conv_id, user_id) + if session.sdk_session: + await session.sdk_session.interrupt() + return True + return False - return output + async def approve(self, conv_id: str, approved: bool) -> None: + """Resolve a pending tool approval for a session.""" + session = self._sessions.get(conv_id) + if session and session.sdk_session: + await session.sdk_session.approve(approved) + + # --- Close, list, permission --- async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool: async with self._lock: @@ -184,6 +171,8 @@ class SessionManager: return False if session.owner_id and user_id and session.owner_id != user_id: raise PermissionError(f"Session {conv_id} belongs to another user") + if session.sdk_session: + await session.sdk_session.close() del self._sessions[conv_id] self._save() logger.info("Closed session %s", conv_id) @@ -198,10 +187,8 @@ class SessionManager: "conv_id": s.conv_id, "cwd": s.cwd, "owner_id": s.owner_id[-8:] if s.owner_id else None, - "cc_session_id": s.cc_session_id, - "started": s.started, + "busy": s.sdk_session._busy if s.sdk_session else False, "idle_timeout": s.idle_timeout, - "cc_timeout": s.cc_timeout, "permission_mode": s.permission_mode, } for s in sessions @@ -217,9 +204,31 @@ class SessionManager: if mode not in VALID_PERMISSION_MODES: raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}") session.permission_mode = mode + if session.sdk_session: + asyncio.create_task(session.sdk_session.set_permission_mode(mode)) self._save() logger.info("Set permission_mode=%s for session %s", mode, conv_id) + # --- Internal --- + + def _get_session(self, conv_id: str, user_id: Optional[str] = None) -> 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") + return session + + def _ensure_sdk_session(self, session: Session, chat_id: str | None = None) -> None: + if session.sdk_session is None: + session.sdk_session = SDKSession( + conv_id=session.conv_id, + cwd=session.cwd, + owner_id=session.owner_id, + permission_mode=session.permission_mode, + chat_id=chat_id or session.chat_id, + ) + def _save(self) -> None: try: data = {cid: s.to_dict() for cid, s in self._sessions.items()} @@ -255,6 +264,9 @@ class SessionManager: if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout: to_close.append(cid) for cid in to_close: + session = self._sessions[cid] + if session.sdk_session: + await session.sdk_session.close() del self._sessions[cid] logger.info("Reaped idle session %s", cid) if to_close: diff --git a/agent/sdk_hooks.py b/agent/sdk_hooks.py new file mode 100644 index 0000000..f7b1923 --- /dev/null +++ b/agent/sdk_hooks.py @@ -0,0 +1,72 @@ +"""SDK hooks for audit logging and dangerous command blocking.""" + +from __future__ import annotations + +import re + +from claude_agent_sdk import HookContext, HookInput, HookJSONOutput, HookMatcher + +BLOCKED_PATTERNS = [ + r"\brm\s+-rf\s+/", + r"\brm\s+-rf\s+~", + r"\bformat\s+", + r"\bmkfs\b", + r"\bshutdown\b", + r"\breboot\b", + r"\bdd\s+if=", + r":\(\)\{:\|:&\};:", + r"\bchmod\s+777\s+/", + r"\bchown\s+.*\s+/", + r"\bsudo\s+rm\b", + r"\bsudo\s+chmod\b", + r"\bsudo\s+chown\b", + r"\bsudo\s+dd\b", + r"\bkill\s+-9\s+1\b", +] + + +async def audit_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """PostToolUse hook — log tool calls to audit JSONL.""" + from agent.audit import log_tool_use + + log_tool_use( + session_id=input_data.get("session_id", ""), + tool_name=input_data.get("tool_name", ""), + tool_input=input_data.get("tool_input", {}), + tool_response=input_data.get("tool_response"), + ) + return {} + + +async def deny_dangerous_hook( + input_data: HookInput, tool_use_id: str | None, context: HookContext +) -> HookJSONOutput: + """PreToolUse hook — block dangerous Bash commands.""" + if input_data.get("tool_name") != "Bash": + return {} + + command = input_data.get("tool_input", {}).get("command", "") + for pattern in BLOCKED_PATTERNS: + if re.search(pattern, command, re.IGNORECASE): + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"Blocked by policy: matches {pattern}", + } + } + return {} + + +def build_hooks(conv_id: str) -> dict[str, list[HookMatcher]]: + """Build hooks configuration for a session.""" + return { + "PostToolUse": [ + HookMatcher(matcher="Bash|Edit|Write|MultiEdit", hooks=[audit_hook]), + ], + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[deny_dangerous_hook]), + ], + } diff --git a/agent/sdk_session.py b/agent/sdk_session.py new file mode 100644 index 0000000..c5d5546 --- /dev/null +++ b/agent/sdk_session.py @@ -0,0 +1,355 @@ +"""SDK-based Claude Code session — secretary model. + +Messages are buffered in memory, not pushed to Feishu in real-time. +Only key events (completion, error, approval) trigger notifications. +The secretary AI queries get_progress() to answer user questions. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Any + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + PermissionMode, + PermissionResult, + PermissionResultAllow, + PermissionResultDeny, + ResultMessage, + SystemMessage, + TextBlock, + ToolPermissionContext, + ToolUseBlock, +) + +logger = logging.getLogger(__name__) + +VALID_PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk"] +DEFAULT_PERMISSION_MODE = "default" +APPROVAL_TIMEOUT = 120 # seconds + + +@dataclass +class SessionProgress: + """Session progress snapshot for the secretary AI to inspect.""" + + busy: bool = False + current_prompt: str = "" + started_at: float = 0.0 + elapsed_seconds: float = 0.0 + text_messages: list[str] = field(default_factory=list) + tool_calls: list[str] = field(default_factory=list) + last_result: str = "" + error: str = "" + pending_approval: str = "" # non-empty → waiting for approval, value is tool description + + +class SDKSession: + """One session = one long-lived ClaudeSDKClient + background message buffer loop. + + Secretary model design: + - _message_loop buffers all messages to memory, does NOT push to Feishu + - Only pushes on key events: completion (ResultMessage), error, approval needed + - get_progress() returns a snapshot for the secretary AI to inspect + """ + + MAX_BUFFER_TEXTS = 20 + MAX_BUFFER_TOOLS = 50 + + def __init__( + self, + conv_id: str, + cwd: str, + owner_id: str, + permission_mode: str = DEFAULT_PERMISSION_MODE, + chat_id: str | None = None, + ): + self.conv_id = conv_id + self.cwd = cwd + self.owner_id = owner_id + self.permission_mode = permission_mode + self.chat_id = chat_id + + self.client: ClaudeSDKClient | None = None + self.session_id: str | None = None + + # Message buffers + self._text_buffer: list[str] = [] + self._tool_buffer: list[str] = [] + self._last_result: str = "" + self._error: str = "" + self._current_prompt: str = "" + self._started_at: float = 0.0 + + # Task state + self._message_loop_task: asyncio.Task | None = None + self._busy = False + self._busy_event = asyncio.Event() + self._busy_event.set() # initially idle + + # Approval mechanism + self._pending_approval: asyncio.Future | None = None + self._pending_approval_desc: str = "" + + async def start(self) -> None: + """Create and connect the ClaudeSDKClient, start the message loop.""" + from agent.sdk_hooks import build_hooks + + env = self._build_env() + hooks = build_hooks(self.conv_id) + + options = ClaudeAgentOptions( + cwd=self.cwd, + permission_mode=self.permission_mode, + allowed_tools=[ + "Read", "Glob", "Grep", "Bash", "Edit", "Write", + "MultiEdit", "WebFetch", "WebSearch", + ], + can_use_tool=self._permission_callback, + hooks=hooks, + env=env, + ) + self.client = ClaudeSDKClient(options) + await self.client.connect() + + self._message_loop_task = asyncio.create_task( + self._message_loop(), name=f"sdk-loop-{self.conv_id}" + ) + logger.info("SDKSession %s started in %s", self.conv_id, self.cwd) + + async def send(self, prompt: str, chat_id: str | None = None) -> str: + """Send a message. Returns immediately; execution happens in background.""" + if not self.client: + await self.start() + + if chat_id: + self.chat_id = chat_id + + # If busy, interrupt the current task first + if self._busy: + await self.interrupt() + try: + await asyncio.wait_for(self._busy_event.wait(), timeout=10) + except asyncio.TimeoutError: + pass + + self._busy = True + self._busy_event.clear() + self._current_prompt = prompt + self._started_at = time.time() + self._last_result = "" + self._error = "" + self._text_buffer.clear() + self._tool_buffer.clear() + + await self.client.query(prompt) + return "⏳ 已开始执行" + + async def send_and_wait(self, prompt: str, chat_id: str | None = None) -> str: + """Send and wait for completion. For LLM agent tool calls.""" + await self.send(prompt, chat_id) + await self._busy_event.wait() + return self._last_result or self._error or "(no output)" + + def get_progress(self) -> SessionProgress: + """Return a progress snapshot. Primary query interface for the secretary AI.""" + return SessionProgress( + busy=self._busy, + current_prompt=self._current_prompt, + started_at=self._started_at, + elapsed_seconds=time.time() - self._started_at if self._busy else 0, + text_messages=list(self._text_buffer[-5:]), + tool_calls=list(self._tool_buffer[-10:]), + last_result=self._last_result[:1000], + error=self._error, + pending_approval=self._pending_approval_desc, + ) + + async def interrupt(self) -> None: + """Interrupt the currently running task.""" + if self.client and self._busy: + await self.client.interrupt() + logger.info("SDKSession %s interrupted", self.conv_id) + + async def set_permission_mode(self, mode: PermissionMode) -> None: + """Dynamically change the permission mode.""" + if self.client: + await self.client.set_permission_mode(mode) + self.permission_mode = mode + logger.info("SDKSession %s permission_mode → %s", self.conv_id, mode) + + async def approve(self, approved: bool) -> None: + """Resolve a pending tool approval.""" + if self._pending_approval and not self._pending_approval.done(): + self._pending_approval.set_result(approved) + + async def close(self) -> None: + """Disconnect and clean up.""" + if self._message_loop_task and not self._message_loop_task.done(): + self._message_loop_task.cancel() + try: + await self._message_loop_task + except asyncio.CancelledError: + pass + if self.client: + await self.client.disconnect() + self.client = None + logger.info("SDKSession %s closed", self.conv_id) + + # --- Internal --- + + async def _message_loop(self) -> None: + """Background message consumption loop. Buffers messages, notifies on key events.""" + from agent.audit import log_interaction + + try: + async for msg in self.client.receive_messages(): + if isinstance(msg, SystemMessage) and msg.subtype == "init": + self.session_id = msg.data.get("session_id") + + elif isinstance(msg, AssistantMessage): + for block in msg.content: + if isinstance(block, TextBlock): + self._text_buffer.append(block.text) + if len(self._text_buffer) > self.MAX_BUFFER_TEXTS: + self._text_buffer.pop(0) + elif isinstance(block, ToolUseBlock): + summary = f"{block.name}({self._summarize_input(block.input)})" + self._tool_buffer.append(summary) + if len(self._tool_buffer) > self.MAX_BUFFER_TOOLS: + self._tool_buffer.pop(0) + + elif isinstance(msg, ResultMessage): + self._last_result = msg.result or "" + self._busy = False + self._busy_event.set() + + # Key event: task completed → notify Feishu + if self.chat_id: + await self._notify_completion() + + log_interaction( + conv_id=self.conv_id, + prompt=self._current_prompt, + response=self._last_result[:2000], + cwd=self.cwd, + user_id=self.owner_id, + ) + + except asyncio.CancelledError: + logger.debug("Message loop cancelled for %s", self.conv_id) + except Exception as exc: + logger.exception("Message loop error for %s", self.conv_id) + self._error = str(exc) + self._busy = False + self._busy_event.set() + if self.chat_id: + await self._notify_error(str(exc)) + + async def _notify_completion(self) -> None: + from bot.feishu import send_markdown + + result_preview = self._last_result[:800] + if len(self._last_result) > 800: + result_preview += "\n...[truncated]" + elapsed = int(time.time() - self._started_at) + tools_used = len(self._tool_buffer) + msg = f"✅ **任务完成** ({elapsed}s, {tools_used} tool calls)\n\n{result_preview}" + try: + await send_markdown(self.chat_id, "chat_id", msg) + except Exception: + logger.exception("Failed to notify completion") + + async def _notify_error(self, error: str) -> None: + from bot.feishu import send_markdown + + try: + await send_markdown( + self.chat_id, "chat_id", + f"❌ **任务出错**\n\n```\n{error[:500]}\n```", + ) + except Exception: + logger.exception("Failed to notify error") + + async def _permission_callback( + self, tool_name: str, input_data: dict, context: ToolPermissionContext + ) -> PermissionResult: + """can_use_tool — send approval card to Feishu, wait for card callback.""" + # Auto-allow read-only tools + if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"): + return PermissionResultAllow() + + if not self.chat_id: + return PermissionResultAllow() + + # Send approval card + from bot.feishu import send_card, build_approval_card + + summary = self._format_tool_summary(tool_name, input_data) + self._pending_approval_desc = f"{tool_name}: {summary}" + + card = build_approval_card( + conv_id=self.conv_id, + tool_name=tool_name, + summary=summary, + timeout=APPROVAL_TIMEOUT, + ) + await send_card(self.chat_id, "chat_id", card) + + # Wait for card callback or text reply y/n + loop = asyncio.get_running_loop() + self._pending_approval = loop.create_future() + try: + approved = await asyncio.wait_for( + self._pending_approval, timeout=APPROVAL_TIMEOUT + ) + except asyncio.TimeoutError: + approved = False + from bot.feishu import send_markdown + + await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。") + finally: + self._pending_approval_desc = "" + + from agent.audit import log_permission_decision + + log_permission_decision( + conv_id=self.conv_id, + tool_name=tool_name, + tool_input=input_data, + approved=approved, + ) + if approved: + return PermissionResultAllow() + return PermissionResultDeny(message="用户拒绝了此操作") + + def _format_tool_summary(self, tool_name: str, input_data: dict) -> str: + if tool_name == "Bash": + return f"`{input_data.get('command', '')[:200]}`" + if tool_name in ("Edit", "Write", "MultiEdit"): + return f"file: `{input_data.get('file_path', input_data.get('path', ''))}`" + return str(input_data)[:200] + + @staticmethod + def _summarize_input(input_data: dict) -> str: + if "command" in input_data: + return input_data["command"][:80] + if "file_path" in input_data: + return input_data["file_path"] + return str(input_data)[:60] + + def _build_env(self) -> dict[str, str]: + import os + + env = {} + for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"): + val = os.environ.get(key, "") + if val: + env[key] = val + return env diff --git a/bot/commands.py b/bot/commands.py index eee1be6..378e7e6 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -12,7 +12,7 @@ from typing import Optional, Tuple from agent.manager import manager from agent.scheduler import scheduler from agent.task_runner import task_runner -from agent.cc_runner import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE +from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE from orchestrator.agent import agent from orchestrator.tools import set_current_user, get_current_chat @@ -24,12 +24,14 @@ _PERM_ALIASES: dict[str, str] = { "default": "default", "edit": "acceptEdits", "plan": "plan", + "auto": "dontAsk", } _PERM_LABELS: dict[str, str] = { "default": "default", "bypassPermissions": "bypass", "acceptEdits": "edit", "plan": "plan", + "dontAsk": "auto", } @@ -105,6 +107,10 @@ async def handle_command(user_id: str, text: str) -> Optional[str]: return await _cmd_remind(args) elif cmd == P+"perm": return await _cmd_perm(user_id, args) + elif cmd in (P+"stop", P+"interrupt"): + return await _cmd_stop(user_id) + elif cmd in (P+"progress", P+"prog", P+"p"): + return await _cmd_progress(user_id) elif cmd in (P+"nodes", P+"node"): return await _cmd_nodes(user_id, args) else: @@ -114,26 +120,25 @@ async def handle_command(user_id: str, text: str) -> Optional[str]: async def _cmd_new(user_id: str, args: str) -> str: """Create a new session.""" if not args: - return "Usage: /new [initial_message] [--timeout N] [--perm MODE]\nModes: default, edit, plan, bypass" + return "Usage: /new [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto" parser = argparse.ArgumentParser() parser.add_argument("working_dir", nargs="?", help="Project directory") parser.add_argument("rest", nargs="*", help="Initial message") - 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("--perm", default=None, help="Permission mode: default, edit, plan, bypass") + parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto") try: parsed = parser.parse_args(args.split()) except SystemExit: - return "Usage: /new [initial_message] [--timeout N] [--idle N] [--perm MODE]" + return "Usage: /new [initial_message] [--idle N] [--perm MODE]" if not parsed.working_dir: 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: default, edit, plan, bypass" + return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto" working_dir = parsed.working_dir initial_msg = " ".join(parsed.rest) if parsed.rest else None @@ -147,13 +152,14 @@ async def _cmd_new(user_id: str, args: str) -> str: import uuid as _uuid conv_id = str(_uuid.uuid4())[:8] + chat_id = get_current_chat() 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, + chat_id=chat_id, ) agent._active_conv[user_id] = conv_id @@ -161,7 +167,6 @@ async def _cmd_new(user_id: str, args: str) -> str: if initial_msg: response = await manager.send(conv_id, initial_msg, user_id=user_id) - chat_id = get_current_chat() if chat_id: from bot.feishu import send_card, send_text, build_sessions_card sessions = manager.list_sessions(user_id=user_id) @@ -174,8 +179,6 @@ async def _cmd_new(user_id: str, args: str) -> str: perm_label = _perm_label(permission_mode) reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]" - if parsed.timeout: - reply += f" (timeout: {parsed.timeout}s)" if initial_msg and response: reply += f"\n\n{response}" return reply @@ -289,11 +292,12 @@ async def _cmd_perm(user_id: str, args: str) -> str: if not parts: return ( "Usage: /perm [conv_id]\n" - "Modes: default, edit, plan, bypass\n" + "Modes: default, edit, plan, bypass, auto\n" " default — default mode\n" " edit — auto-accept file edits, confirm shell commands\n" " plan — plan only, no writes\n" - " bypass — skip all permission checks" + " bypass — skip all permission checks\n" + " auto — allow all tools, don't ask" ) alias = parts[0] @@ -403,6 +407,38 @@ async def _cmd_remind(args: str) -> str: return f"⏰ Reminder #{job_id} set for {value}{unit} from now" +async def _cmd_stop(user_id: str) -> str: + """Interrupt the current task in the active session.""" + conv_id = agent.get_active_conv(user_id) + if not conv_id: + return "No active session." + try: + success = await manager.interrupt(conv_id, user_id) + return "✓ Interrupted" if success else "No active task." + except Exception as e: + return f"Error: {e}" + + +async def _cmd_progress(user_id: str) -> str: + """Show progress of the active session.""" + conv_id = agent.get_active_conv(user_id) + if not conv_id: + return "No active session." + progress = manager.get_progress(conv_id, user_id) + if not progress: + return "Session not found." + if not progress.busy: + if progress.last_result: + return f"✅ 已完成\n\n{progress.last_result[:500]}" + return "空闲中,无正在执行的任务。" + elapsed = int(progress.elapsed_seconds) + tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none" + pending = "" + if progress.pending_approval: + pending = f"\n⚠️ 等待审批: {progress.pending_approval}" + return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}" + + async def _cmd_nodes(user_id: str, args: str) -> str: """List nodes or switch active node.""" from config import ROUTER_MODE @@ -440,11 +476,13 @@ def _cmd_help() -> str: """Show help.""" from config import COMMAND_PREFIX as P return f"""**Commands:** (prefix: `{P}`) -{P}new [msg] [--timeout N] [--idle N] [--perm MODE] - Create session +{P}new [msg] [--idle N] [--perm MODE] - Create session {P}status - Show sessions and current mode {P}close [n] - Close session (active or by number) {P}switch - Switch to session by number -{P}perm [conv_id] - Set permission mode (default/edit/plan/bypass) +{P}perm [conv_id] - Set permission mode (default/edit/plan/bypass/auto) +{P}stop - Interrupt the current task +{P}progress - Show task progress {P}direct - Direct mode: messages → Claude Code (no LLM overhead) {P}smart - Smart mode: messages → LLM routing (default) {P}shell - Run shell command (bypasses LLM) @@ -462,4 +500,5 @@ def _cmd_help() -> str: plan — 只规划、不执行任何写操作 适合:先预览 CC 的操作计划再决定是否执行 bypass — 跳过所有权限确认,CC 自动执行一切操作 - 适合:受信任的沙盒环境、自动化任务""" \ No newline at end of file + 适合:受信任的沙盒环境、自动化任务 + auto — 允许所有工具,不询问(等效 bypass + dontAsk)""" \ No newline at end of file diff --git a/bot/feishu.py b/bot/feishu.py index 8b1b416..ca3e5e5 100644 --- a/bot/feishu.py +++ b/bot/feishu.py @@ -128,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode: lines = [] for i, s in enumerate(sessions, 1): marker = "→" if s["conv_id"] == active_conv_id else " " - started = "🟢" if s["started"] else "🟡" - lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`") + status = "🔵" if s.get("busy") else "⚪" + lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`") sessions_md = "\n".join(lines) else: sessions_md = "_No active sessions_" @@ -148,6 +148,48 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode: } +def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int = 120) -> dict: + """Build an approval card for a tool call (schema 2.0, with approve/deny buttons).""" + return { + "schema": "2.0", + "header": { + "title": {"tag": "plain_text", "content": "🔐 权限审批"}, + "template": "orange", + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": f"**工具:** `{tool_name}`\n**参数:** {summary}", + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "✅ 批准"}, + "type": "primary", + "value": {"action": "approve", "conv_id": conv_id}, + }, + { + "tag": "button", + "text": {"tag": "plain_text", "content": "❌ 拒绝"}, + "type": "danger", + "value": {"action": "deny", "conv_id": conv_id}, + }, + ], + }, + { + "tag": "note", + "elements": [ + {"tag": "plain_text", "content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n"}, + ], + }, + ], + }, + } + + async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None: """ Upload a local file to Feishu and send it as a file message. diff --git a/bot/handler.py b/bot/handler.py index 321f5f5..bc9badf 100644 --- a/bot/handler.py +++ b/bot/handler.py @@ -129,6 +129,25 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None: await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.") return + # Text approval fallback: user replies y/n to a pending tool approval + if text.strip().lower() in ("y", "n", "yes", "no"): + approved = text.strip().lower() in ("y", "yes") + from orchestrator.agent import agent as _agent + from agent.manager import manager as _manager + conv_id = _agent.get_active_conv(user_id) + if conv_id: + session = _manager._sessions.get(conv_id) + if ( + session + and session.sdk_session + and session.sdk_session._pending_approval + and not session.sdk_session._pending_approval.done() + ): + await _manager.approve(conv_id, approved) + label = "✅ 已批准" if approved else "❌ 已拒绝" + await send_text(chat_id, "chat_id", label) + return + from config import ROUTER_MODE if ROUTER_MODE: from router.nodes import get_node_registry @@ -196,12 +215,48 @@ def _handle_any(data: lark.CustomizedEvent) -> None: logger.info("RAW CustomizedEvent: %s", marshaled[:500]) +def _handle_card_action(data: lark.CustomizedEvent) -> None: + """Handle Feishu card button clicks (approval approve/deny).""" + try: + marshaled = lark.JSON.marshal(data) + if not marshaled: + return + + payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled + action = payload.get("event", {}).get("action", {}) + value = action.get("value", {}) + + action_type = value.get("action") # "approve" or "deny" + conv_id = value.get("conv_id") + + if not action_type or not conv_id: + logger.debug("Card action without action/conv_id: %s", value) + return + + approved = action_type == "approve" + logger.info("Card action: %s for session %s", action_type, conv_id) + + if _main_loop: + asyncio.run_coroutine_threadsafe( + _handle_approval_async(conv_id, approved), _main_loop + ) + except Exception: + logger.exception("Error handling card action") + + +async def _handle_approval_async(conv_id: str, approved: bool) -> None: + """Process a card approval action.""" + from agent.manager import manager + await manager.approve(conv_id, approved) + + def build_event_handler() -> lark.EventDispatcherHandler: """Construct the EventDispatcherHandler with all registered callbacks.""" handler = ( lark.EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(_handle_message) .register_p1_customized_event("im.message.receive_v1", _handle_any) + .register_p1_customized_event("card.action.trigger", _handle_card_action) .build() ) return handler diff --git a/config.py b/config.py index 8bbfa18..b597051 100644 --- a/config.py +++ b/config.py @@ -21,6 +21,10 @@ OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"] OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7") WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve() METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "") +ANTHROPIC_API_KEY: str = _cfg.get("ANTHROPIC_API_KEY", "") + +# SDK approval timeout (seconds) for can_use_tool callback +SDK_APPROVAL_TIMEOUT: int = _cfg.get("SDK_APPROVAL_TIMEOUT", 120) ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False) ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "") diff --git a/conftest.py b/conftest.py index 749278b..b6f34af 100644 --- a/conftest.py +++ b/conftest.py @@ -1,16 +1,18 @@ """ 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. +production modules. Creates a temporary keyring.yaml from the test keyring +so that `import config` works without the real keyring.yaml. Must live at the repo root (not inside tests/) to fire before collection. """ +import shutil from pathlib import Path -import importlib -_TEST_KEYRING = Path(__file__).parent / "tests" / "keyring_test.yaml" +_REPO_ROOT = Path(__file__).parent +_TEST_KEYRING = _REPO_ROOT / "tests" / "keyring_test.yaml" +_KEYRING = _REPO_ROOT / "keyring.yaml" -# Patch config before anything else imports it -import config as _config_mod -_config_mod._CONFIG_PATH = _TEST_KEYRING -importlib.reload(_config_mod) +# If the real keyring.yaml doesn't exist, copy the test version so config.py +# can load at module import time. This file is gitignored. +if not _KEYRING.exists() and _TEST_KEYRING.exists(): + shutil.copy2(_TEST_KEYRING, _KEYRING) diff --git a/docs/feishu/card_callback.md b/docs/feishu/card_callback.md new file mode 100644 index 0000000..59a5f4c --- /dev/null +++ b/docs/feishu/card_callback.md @@ -0,0 +1,72 @@ +# 回调概述 + +回调适用于需要对用户行为进行同步响应的业务场景,即当用户在飞书中触发某些操作时,前端加载等待服务端返回响应数据。待服务端返回响应结果时,前端加载完成,并向用户展示返回的响应结果。 + +在飞书业务中,回调功能的典型使用场景如下: + +- **卡片交互场景**:用户点击卡片上的交互组件(比如审批卡片上的同意/拒绝按钮),开发者的服务端将收到按钮的点击回调,并且需要立即响应更新后的卡片内容,给予用户操作反馈(比如把审批状态流转为已审批)。 + +- **链接预览场景**:用户在聊天中查看某个应用链接,该链接支持返回应用配置的[预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/link-preview-development-guide),此时该应用的服务端会收到[拉取链接预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/pull-link-preview-data-callback-structure)的回调,并且需要立即响应返回链接预览内容,从而使终端用户看到链接预览效果。 + +## 回调与事件的区别 + +![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/88c9a80327f80bc5ddce17babe63f64d_bWeBkMFagD.png?height=440&lazyload=true&maxWidth=600&width=1600) + +回调与[事件](https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM)相似但又有不同: +- **相似点:** + - 都是飞书服务器主动向开发者服务器推送数据。 + - 回调与事件有相似的数据结构,可以复用同一套加密解密策略,开发者在解析飞书返回的内容时,可以采取同一套策略。 +- **差异点:** + - 订阅回调后,开发者服务器需要**立即返回**响应内容,以反馈用户操作,而事件则不要求返回。 + - 回调是同步操作,不提供补推机制,超时未响应即认为这次回调失败,前端会展示报错等平台提供的兜底响应策略。 + - 事件是异步操作,开发者只需简单响应飞书服务器是否收到事件即可,如开发者未响应,则平台会补推送事件。 + +## 订阅流程 + +步骤 | 说明 +---|--- +1. 选择回调订阅方式 | 回调订阅方式分为 **使用长连接接收回调** 和 **将回调发送至开发者服务器** 两种,你可以根据需要自行选择任一订阅方式。
**注意事项**:- **使用长连接接收回调** 方式是飞书 SDK 内提供的能力,你可以通过集成飞书 SDK 与开放平台建立一条 WebSocket 全双工通道(你的服务器需要能够访问公网)。后续当应用订阅的回调发生时,开放平台会通过该通道向你的服务器发送消息。详细配置说明参见[使用长连接接收回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address)。
- **将回调发送至开发者服务器** 方式是传统的 Webhook 模式,该方式需要你提供用于接收回调消息的服务器公网地址。后续当应用订阅的回调发生时,开放平台会向服务器的公网地址发送 HTTP POST 请求,请求内包含回调数据。详细配置说明参见[将回调发送至开发者服务器](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/send-callbacks-to-developers-server)。 +2. 添加所需回调 | 完成回调订阅方式配置后,即可为应用添加所需订阅的回调,并发布应用使配置生效。具体操作参见[添加回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/add-callback)。 +3. 接收回调 | 根据不同的回调订阅方式接收回调:
- **使用长连接接收回调** 方式已经封装了鉴权逻辑,无需进行数据解密与验签操作,直接接收来自开放平台的回调请求即可。
- **将回调发送至开发者服务器** 方式需要你根据应用的加密策略进行安全校验,如果是加密回调,需要先解密回调,再解析回调详情。具体操作参见[接收回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/receive-and-handle-callbacks)。 + +## 回调结构 + +回调返回的数据结构与事件类似。以“拉取链接预览数据”(`url.preview.get`)为例,回调的结构示意如下: + +```json +{ + "schema": "2.0", //表示回调的版本,2.0表示这个回调结构与事件的2.0版本在形式上一致 + "header": { //回调的通用参数 + "token": "vi57noNQoGbhxxxxxWmmWdlsSn3FTzk1", //对应 Verification Token + "create_time": "170134xxxxx18480", //回调发送的时间戳,近似于回调触发的时间 + "event_type": "url.preview.get", //回调类型 + "tenant_key": "736xxxxx260f175d", //回调所属应用的租户id + "app_id": "cli_a40xxxxxe57e100c" //回调所属应用的应用id + }, + "event": { //记录不同回调类型返回的详细的上下文信息 + "operator": { + "tenant_key": "736588cxxxx175d", + "user_id": "c3xxxxd1", + "open_id": "ou_xxxxx54182ea7b8319f4d39823b79d2" + }, + "host": "im_message", //链接所在的宿主场景。枚举包括1.im_message 聊天消息 2.im_top_notice 群置顶 + "context": { //这个场景下具体的上下文参数 + "url": "https://feishu-url.bytedance.net/smartcard/test/111", //匹配URL规则的原链接 + "preview_token": "e28r7df2-xxxx-477d-a8d0-2e1eb99796c2", //用于标识链接预览的凭证,在返回链接预览数据时要用 + "open_message_id": "om_191d914xxxxx81c97a609c663452dfdf", //触发链接预览的消息ID + "open_chat_id": "oc_20443194b65f9c8cf2935818dae39999" //触发链接预览的群ID + } + } +} +``` + +## 回调列表 + +目前支持的回调列表如下: + +功能模块 | 回调名称 | 描述 +---|---|--- +卡片 | [卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication) | 用户点击卡片上配置回传交互的组件时,触发此回调。
可通过返回 toast、更新后的卡片内容等反馈用户的交互。 +链接预览 | [拉取链接预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/pull-link-preview-data-callback-structure) | 用户在聊天中查看匹配应用注册的URL规则的链接时,触发此回调。
可通过返回文字链、卡片等链接预览内容,为裸链扩展链接预览效果。 +卡片 | [消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure) | 当用户点击卡片上添加了回传交互的组件时,开发者注册的服务端回调地址将收到此回调。
开发者可声明通过弹出 toast、更新卡片、保持原内容不变等方式来响应用户交互。
该回调使用旧版的协议,兼容历史的机器人[回调配置](https://open.feishu.cn/document/ukTMukTMukTM/uYzMxEjL2MTMx4iNzETM)。 +q \ No newline at end of file diff --git a/docs/feishu/card_callback_communication.md b/docs/feishu/card_callback_communication.md new file mode 100644 index 0000000..fd248b7 --- /dev/null +++ b/docs/feishu/card_callback_communication.md @@ -0,0 +1,253 @@ +# 卡片回传交互回调 + +**卡片回传交互**作用于飞书卡片的 **请求回调** 交互组件。当终端用户点击飞书卡片上的回传交互组件后,你在开发者后台应用内注册的回调请求地址将会收到 **卡片回传交互** 回调。该回调包含了用户与卡片之间的交互信息。 + +你的业务服务器接收到回调请求后,需要在 3 秒内响应回调请求,声明通过弹出 Toast 提示、更新卡片、保持原内容不变等方式响应用户交互。了解详细的操作步骤,参考[处理卡片回调](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/handle-card-callbacks)。 + +卡片回调和服务端响应回调的结构体参考下文。 +**注意事项**:- 本文档提供新版本的卡片回调结构和响应示例。开放平台 SDK 已全量支持新版卡片回调。 +- 了解旧版回调的 SDK 调用,参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)。 + +## 回调 + +基本信息 |   +---|--- +回调类型 | card.action.trigger +支持的应用类型 | Custom App、Store App +权限要求
**订阅该事件所需的权限,开启其中任意一项权限即可订阅**
开启任一权限即可 | 暂无 +字段权限要求 | **注意事项**:事件结构体中存在 `user_id` 敏感字段,仅当应用开启“获取用户 user ID”权限后才会返回。
获取用户 user ID(contact:user.employee_id:readonly) +推送方式 | [Webhook](https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM) + +## 回调结构体 + +字段 | 数据类型 | 描述 +---|---|--- +schema | string | 回调的版本。固定取值为 `2.0`,为最新版本回调。了解旧版本回调,参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)。 +header | object | 回调基本信息。 +event_id | string | 回调的唯一标识。 +token | string | 应用的 Verification Token。 +create_time | string | 回调发送的时间,接近回调发生的时间。微秒级时间戳。 +event_type | string | 回调类型。卡片交互场景中,固定为 `"card.action.trigger"`。 +tenant_key | string | 应用归属的 tenant key,即租户唯一标识。 +app_id | string | 应用的 App ID。 +event | object | 回调的详细信息。 +operator | object | 回调触发者信息。 +tenant_key | string | 回调触发者的 tenant key,即租户唯一标识。 +user_id | string | 回调触发者的 user_id。了解不同的用户 ID,参见[用户身份概述](https://open.feishu.cn/document/home/user-identity-introduction/introduction)。 +union_id | string | 回调触发者的 union_id。 +open_id | string | 回调触发者的 open_id。 +token | string | [更新卡片](https://open.feishu.cn/document/ukTMukTMukTM/uMDO1YjLzgTN24yM4UjN)用的凭证,有效期为 30 分钟,最多可更新 2 次。 +action | object | 交互信息。 +value | object/ string | 交互组件绑定的开发者自定义回传数据,对应组件中的 value 属性。类型为 string 或 object,可由开发者指定。 +tag | string | 交互组件的标签。 +timezone | string | 用户当前所在地区的时区。当用户操作日期选择器、时间选择器、或日期时间选择器时返回。 +name | string | 组件的自定义唯一标识,用于识别内嵌在表单容器中的某个组件。 +form_value | object | 表单容器内用户提交的数据。示例值:
```JSON
{
"field name 1": [ // 表单容器内某多选组件的 name 和 value
"selectDemo1",
"selectDemo2"
],
"field name 2": "value 2", // 表单容器内某交互组件的 name 和 value
"field name 3": "value 3", // 表单容器内某交互组件的 name 和 value
}
``` +input_value | string | 当输入框组件未内嵌在表单容器中时,用户在输入框中提交的数据。 +option | string | 当折叠按钮组、下拉选择-单选、人员选择-单选、日期选择器、时间选择器、日期时间选择器组件未内嵌在表单容器中时,用户选择该类组件某个选项时,组件返回的选项回调值。 +options | string[] | 当下拉选择-多选组件和人员选择-多选组件未内嵌在表单容器中时,用户选择该类组件某个选项时,组件返回的选项回调值。 +checked | bool | 当勾选器组件未内嵌在表单容器中时,勾选器组件的回调数据。 +host | string | 卡片展示场景。 +delivery_type | string | 卡片分发类型,固定取值为 `url_preview`,表示链接预览卡片。仅链接预览卡片有此字段。 +context | object | 展示场景上下文。 +url | string | 链接地址(适用于链接预览场景)。 +preview_token | string | 链接预览的 token(适用于链接预览场景)。 +open_message_id | string | 消息 ID。 +open_chat_id | string | 会话 ID。 + +## 回调结构体示例 + +```json +{ + "schema": "2.0", // 回调的版本 + "header": { // 回调基本信息 + "event_id": "f7984f25108f8137722bb63c*****", // 回调的唯一标识 + "token": "066zT6pS4QCbgj5Do145GfDbbag*****", // 应用的 Verification Token + "create_time": "1603977298000000", // 回调发送的时间,接近回调发生的时间。微秒级时间戳 + "event_type": "card.action.trigger", // 回调类型卡片交互场景中,固定为 "card.action.trigger" + "tenant_key": "2df73991750*****", // 应用归属的 tenant key,即租户唯一标识 + "app_id": "cli_a5fb0ae6a4******" // 应用的 App ID + }, + "event": { // 回调的详细信息 + "operator": { // 回调触发者信息 + "tenant_key": "2df73991750*****", // 回调触发者的 tenant key,即租户唯一标识 + "user_id": "867*****", // 回调触发者的 user ID。当应用开启“获取用户 user ID”权限后,该参数返回 + "open_id": "ou_3c14f3a59eaf2825dbe25359f15*****", // 回调触发者的 Open ID + "union_id": "on_cad4860e7af114fb4ff6c5d496d*****" // 回调触发者的 Union ID + }, + "token": "c-295ee57216a5dc9de90fefd0aadb4b1d7d******", // 更新卡片用的凭证,有效期为 30 分钟,最多可更新 2 次 + "action": { // 用户操作交互组件回传的数据 + "value": { // 交互组件绑定的开发者自定义回传数据,对应组件中的 value 属性。类型为 string 或 object,可由开发者指定。 + "key": "value" + }, + "tag": "button", // 交互组件的标签 + "timezone": "Asia/Shanghai", // 用户当前所在地区的时区。当用户操作日期选择器、时间选择器、或日期时间选择器时返回 + "form_value": { // 表单容器内用户提交的数据 + "field name1": [ // 表单容器内某多选组件的 name 和 value + "selectDemo1", + "selectDemo2" + ], + "field name2": "value2", // 表单容器内某交互组件的 name 和 value + "DatePicker_bpqdq5puvn4": "2024-04-01 +0800", // 表单容器内日期选择器组件的 name 和 value + "DateTimePicker_ihz2d7a74i": "2024-04-29 07:07 +0800", // 表单容器内日期时间选择器组件的 name 和 value + "Input_lf4fmxwfrd9": "1234", // 表单容器内输入框组件的 name 和 value + "PersonSelect_2ejys7ype7m": "ou_3c14f3a59eaf2825dbe25359f15*****", // 表单容器内人员选择-单选组件的 name 和 value + "Select_a2d5b7l3zd": "1", // 表单容器内下拉选择-单选组件的 name 和 value + "TimePicker_7ecsf6xkqsq": "00:00 +0800" // 表单容器内时间选择器组件的 name 和 value + }, + "name": "Button_lvkepfu3" // 用户操作交互组件的名称,由开发者自定义 + }, + "host": "im_message", // 卡片展示场景 + "delivery_type": "url_preview", // 卡片分发类型,固定取值为 url_preview,表示链接预览卡片仅链接预览卡片有此字段 + "context": { // 卡片展示场景相关信息 + "url": "xxx", // 链接地址(适用于链接预览场景) + "preview_token": "xxx", // 链接预览的 token(适用于链接预览场景) + "open_message_id": "om_574d639e4a44e4dd646eaf628e2*****", // 卡片所在的消息 ID + "open_chat_id": "oc_e4d2605ca917e695f54f11aaf56*****" // 卡片所在的会话 ID + } + } +} +``` + +## 响应回调的结构体 + +你的业务服务器接收到回调请求后,需要在 3 秒内响应回调请求,声明通过弹出 Toast 提示、更新卡片、保持原内容不变等方式响应用户交互。以下为使用卡片 JSON 代码和卡片模板响应的字段说明。要了解响应方式,参考[处理卡片回调](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/handle-card-callbacks)。warning +业务服务端不可使用重定向状态码(`HTTP 3xx`)来响应卡片的回调请求,否则用户端将会出现交互请求错误。 + +### 使用卡片 JSON 代码响应 + +字段 | 数据类型 | 是否必填 | 描述 +---|---|---|--- +toast | object | 否 | 客户端的 Toast 弹窗提示。 +type | string | 否 | 弹窗提示的类型。可选值有:info、success、error、和 warning。
不同的值的展示效果如下图所示:
![img_v3_02ao_9fdce3f7-5ba1-4f86-941f-2e5e7f6fd4eg.jpg](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/e62145dca9a372b1b51f0ea2e2629160_y1gPzFePcx.jpg?height=844&lazyload=true&width=1280) +content | string | 否 | 单语言提示文案。要配置多语言提示文案,请使用 `i18n` 字段。 +i18n | Map | 否 | 多语言提示文案。示例配置:
```json
{
"i18n": {
"zh_cn": "更新成功!",
"en_us": "Successful update"
}
}
``` +key | string | 否 | 语言。可选值:
- `zh_cn`: 简体中文
- `en_us`: 英文
- `zh_hk`: 繁体中文(香港)
- `zh_tw`: 繁体中文(台湾)
- `ja_jp`: 日语
- `id_id`: 印尼语
- `vi_vn`: 越南语
- `th_th`: 泰语
- `pt_br`: 葡萄牙语
- `es_es`: 西班牙语
- `ko_kr`: 韩语
- `de_de`: 德语
- `fr_fr`: 法语
- `it_it`: 意大利语
- `ru_ru`: 俄语
- `ms_my`: 马来语 +value | string | 否 | 语言对应的文案。 +card | object | 否 | 卡片数据。 +type | string | 是 | 卡片类型。可选值:
- `template`:搭建工具构建的卡片,可视为一个卡片模板
- `raw`:由 JSON 构建的卡片
要使用卡片 JSON 代码响应,请选择 `raw`。 +data | object | 是 | 卡片的 JSON 数据。
- 若发送卡片时,卡片 JSON 结构为 1.0 版本,那么你需传入卡片 JSON 1.0 数据。详情参考[卡片 JSON 1.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-structure)
- 若发送卡片时,卡片 JSON 结构为 2.0 版本,那么你需传入卡片 JSON 2.0 数据。详情参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure) + +响应回调的结构体示例(以 JSON 2.0 结构为例) + +```json +{ + "toast": { + "type": "info", + "content": "卡片交互成功", + "i18n": { + "zh_cn": "卡片交互成功", + "en_us": "card action success" + } + }, + "card": { + "type": "raw", + "data": { + "schema": "2.0", + "config": { + "update_multi": true, + "style": { + "text_size": { + "normal_v2": { + "default": "normal", + "pc": "normal", + "mobile": "heading" + } + } + } + }, + "body": { + "direction": "vertical", + "padding": "12px 12px 12px 12px", + "elements": [ + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": "示例文本", + "text_size": "normal_v2", + "text_align": "left", + "text_color": "default" + }, + "margin": "0px 0px 0px 0px" + } + ] + }, + "header": { + "title": { + "tag": "plain_text", + "content": "示例标题" + }, + "subtitle": { + "tag": "plain_text", + "content": "示例文本" + }, + "template": "blue", + "padding": "12px 12px 12px 12px" + } + } + } +} +``` + +### 使用卡片模板响应 + +字段 | 数据类型 | 是否必填 | 描述 +---|---|---|--- +toast | object | 否 | 客户端的 Toast 弹窗提示。 +type | string | 否 | 弹窗提示的类型。可选值有:info、success、error、和 warning。
不同的值的展示效果如下图所示:
![img_v3_02ao_9fdce3f7-5ba1-4f86-941f-2e5e7f6fd4eg.jpg](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/e62145dca9a372b1b51f0ea2e2629160_y1gPzFePcx.jpg?height=844&lazyload=true&width=1280) +content | string | 否 | 单语言提示文案。要配置多语言提示文案,请使用 `i18n` 字段。 +i18n | Map | 否 | 多语言提示文案。示例配置:
```json
{
"i18n": {
"zh_cn": "更新成功!",
"en_us": "Successful update"
}
}
``` +key | string | 否 | 语言。可选值:
- `zh_cn`: 简体中文
- `en_us`: 英文
- `zh_hk`: 繁体中文(香港)
- `zh_tw`: 繁体中文(台湾)
- `ja_jp`: 日语
- `id_id`: 印尼语
- `vi_vn`: 越南语
- `th_th`: 泰语
- `pt_br`: 葡萄牙语
- `es_es`: 西班牙语
- `ko_kr`: 韩语
- `de_de`: 德语
- `fr_fr`: 法语
- `it_it`: 意大利语
- `ru_ru`: 俄语
- `ms_my`: 马来语 +value | string | 否 | 语言对应的文案。 +card | object | 否 | 卡片数据。 +type | string | 是 | 卡片类型。可选值:
- `template`:搭建工具构建的卡片,可视为一个卡片模板
- `raw`:由 JSON 构建的卡片
要使用卡片模板响应,请选择 `template`。 +data | object | 是 | 卡片模板的数据。 +template_id | string | 是 | 搭建工具中创建的卡片(也称卡片模板)的 ID,如 AAqigYkzabcef。可在搭建工具中通过复制卡片模板 ID 获取。
![image.png](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/8bf97ff2bceed633b28f5ce2d2ec0270_zPTWqjljT8.png?height=329&lazyload=true&maxWidth=500&width=1574) +template_variable | object | 否 | 若卡片绑定了变量,你需在该字段中传入实际变量数据的值。示例:如果变量名称在搭建工具中被定义为 open_id,则此处需要对 open_id 变量传入值。以“ou_d506829e8b6a17607e56bcd6b1aabcef”为示例:
```json
{
"open_id": "ou_d506829e8b6a17607e56bcd6b1aabcef"
}
``` +template_version_name | string | 否 | 搭建工具中创建的卡片的版本号,如 1.0.0。卡片发布后,将生成版本号。可在搭建工具 **版本管理** 处获取。
![image.png](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/b3e96c8ca7c5c029bdbce6c0ca1ba413_aoV0ao7VUo.png?height=384&lazyload=true&maxWidth=500&width=1459) + +响应回调的结构体示例 + +```json +{ + "toast": { + "type": "info", + "content": "卡片交互成功", + "i18n": { + "zh_cn": "卡片交互成功", + "en_us": "card action success" + } + }, + "card": { + "type": "template", + "data": { + "template_id": "AAqi6xJ8rabcd", + "template_version_name": "1.0.0", + "template_variable": { + "open_id": "ou_d506829e8b6a17607e56bcd6b1aabcef" + } + } + } +} +``` +## 错误码 + +在飞书客户端进行卡片交互时,若交互出错,将返回如下图对应的错误码。错误码说明及解决方案如下表所示。 + +![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/29558d328f22a099dc8ce5c66bf4e5ba_DD7lIR8Lxk.png?height=64&lazyload=true&width=285) +错误码仅支持飞书客户端 7.28 及以上版本。若未返回错误码,请升级飞书客户端后重试。 + +错误码 | 描述 | 解决方案 +---|---|--- +200340 | 应用未配置飞书卡片回调地址或配置的请求地址无效。
若应用已配置,请确保你已创建并发布了最新的应用版本使修改生效。 | 1. 前往[开发者后台](https://open.feishu.cn/app),点击目标应用,选择 **开发配置** > **事件与回调**。
2. 在 **事件与回调** 页面 **回调配置** 页签下,填写正确有效的请求地址并保存。
3. 在 **已订阅的回调** 项中,确保已添加卡片回传交互回调。
**提示**:你也可以选择使用长连接接收回调。了解更多,参考[配置回调订阅方式](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address)。 +200341 | 所请求的卡片回调服务未在规定时间内响应飞书卡片服务端。 | 请确保配置的回调地址能够在 3 秒内响应卡片回调请求。 +200342 | 飞书卡片服务端无法与该卡片回调地址建立 TCP 连接。 | 请检查并确保配置的回调地址可以正常访问。 +200343 | 飞书卡片服务端解析该卡片回调地址的 DNS 失败。 | 请检查并确保配置的回调地址的域名正确。 +200530 | 在表单容器中的交互组件的 name (表单项标识)属性为空。 | `name` 是表单容器内组件的唯一标识,用于识别用户提交的数据属于哪个组件,在单张卡片内不可为空、不可重复。
- 如果你使用卡片 JSON 搭建卡片,请确保所有的 name 属性的值不为空。`name` 数据类型为字符串。
- 如果你使用卡片搭建工具搭建卡片:
1. 在卡片编辑页面,选中表单内的交互组件,在右侧属性页签下,确保 **表单项标识** 已填写。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/93894aaf05f60f3576e64cb5a0f22569_62E0goGKeA.png?height=482&lazyload=true&width=1547)
2. 点击右上角的 **保存**,然后点击 **发布**,确保修改生效。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/b704b7552c24d7956b402092c7c38775_c3K900pZqf.png?height=557&lazyload=true&width=1557) +200080 | 飞书卡片服务端请求该卡片回调地址时发生错误。 | 请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)进行处理。 +200671 | 请求的卡片回调服务返回了非 `HTTP 200` 的状态码,导致无法进行正常的卡片交互。 | 请检查并确保接口代码逻辑正常,确保不会返回异常状态码。 +200672 | 请求的卡片回调服务返回了错误的响应体格式。 | - 如果你添加的是新版卡片回传交互(`card.action.trigger`)回调,请参考[卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication#65787609)检查响应回调的结构体的格式是否有误。
- 如果你添加的是旧版卡片回传交互(`card.action.trigger_v1`)回调,请参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)检查响应回调的结构体的格式是否有误。
- 如果你同时添加了新版和旧版卡片回传交互回调,响应其中任一回调即为成功响应。建议你删除多余的请求方式。 +200673 | 请求的卡片回调服务返回了错误的卡片。 | - 如果你添加的是新版卡片回传交互(`card.action.trigger`)回调,请参考[卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication#65787609)检查响应回调的结构体中 `card` 部分是否有误。
- 如果你添加的是旧版卡片回传交互(`card.action.trigger_v1`)回调,请参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)检查响应回调的结构体中除 `toast` 外的其它部分是否有误。 +200830 | JSON 2.0 结构的卡片无法更新为 JSON 1.0 结构卡片。 | 如果交互前卡片的结构为[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure),交互后的卡片结构仍必须为 2.0 结构。 +300000 | 服务内部错误。 | 请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)。 diff --git a/orchestrator/agent.py b/orchestrator/agent.py index 37a7de3..acbfd59 100644 --- a/orchestrator/agent.py +++ b/orchestrator/agent.py @@ -76,6 +76,18 @@ NEVER use `run_shell` for bot control. NEVER use `run_command` for shell command 5. WEB / SEARCH: use `web` at most twice, then synthesize and reply. 6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`. +## Progress queries +When the user asks about task progress (e.g. "怎么样了?" "做得如何?" "进度"), +use `session_progress` with the active conv_id: +- busy=true → summarize recent_tools to tell the user what CC is doing +- busy=false + last_result → summarize the result +- pending_approval is not empty → remind the user to approve/deny +- error is not empty → report the error details + +## Passthrough mode +Direct mode sends messages straight to the Claude Code session (no LLM overhead). +The user gets an immediate "executing" confirmation; results are pushed to Feishu on completion. + Guidelines: - Relay Claude Code's output verbatim. - If no active session and the user sends a task without naming a directory, ask which project. @@ -159,7 +171,9 @@ class OrchestrationAgent: # Passthrough mode: if enabled and active session, bypass LLM if self._passthrough[user_id] and active_conv: try: - reply = await manager.send(active_conv, text, user_id=user_id, direct=True) + from orchestrator.tools import get_current_chat + chat_id = get_current_chat() + reply = await manager.send_message(active_conv, text, user_id=user_id, chat_id=chat_id) logger.info("<<< [passthrough] reply: %r", reply[:120]) return reply except KeyError: diff --git a/orchestrator/tools.py b/orchestrator/tools.py index d2226ad..44a1675 100644 --- a/orchestrator/tools.py +++ b/orchestrator/tools.py @@ -83,7 +83,6 @@ class CreateConversationInput(BaseModel): ) initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning") idle_timeout: Optional[int] = Field(None, description="Idle timeout in seconds (default 1800)") - cc_timeout: Optional[float] = Field(None, description="Claude Code execution timeout in seconds (default 300)") class SendToConversationInput(BaseModel): @@ -108,23 +107,24 @@ class CreateConversationTool(BaseTool): ) args_schema: Type[BaseModel] = CreateConversationInput - def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str: + def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str: raise NotImplementedError("Use async version") - async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str: + async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str: try: resolved = _resolve_dir(working_dir) except ValueError as exc: return json.dumps({"error": str(exc)}) user_id = get_current_user() + chat_id = get_current_chat() conv_id = str(uuid.uuid4())[:8] await manager.create( conv_id, str(resolved), owner_id=user_id or "", idle_timeout=idle_timeout or 1800, - cc_timeout=cc_timeout or 300.0, + chat_id=chat_id, ) result: dict = { @@ -154,8 +154,9 @@ class SendToConversationTool(BaseTool): async def _arun(self, conv_id: str, message: str) -> str: user_id = get_current_user() + chat_id = get_current_chat() try: - output = await manager.send(conv_id, message, user_id=user_id) + output = await manager.send_and_wait(conv_id, message, user_id=user_id, chat_id=chat_id) return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False) except KeyError: return json.dumps({"error": f"No active session for conv_id={conv_id!r}"}) @@ -752,12 +753,71 @@ class RunCommandTool(BaseTool): return result +class SessionProgressInput(BaseModel): + conv_id: str = Field(..., description="Conversation ID to check progress") + + +class SessionProgressTool(BaseTool): + name: str = "session_progress" + description: str = ( + "Check the progress of a running Claude Code session. " + "Returns: busy status, elapsed time, recent tool calls, " + "recent text output, and any pending approval requests. " + "Use this when the user asks about task status or progress." + ) + args_schema: Type[BaseModel] = SessionProgressInput + + def _run(self, conv_id: str) -> str: + raise NotImplementedError("Use async version") + + async def _arun(self, conv_id: str) -> str: + user_id = get_current_user() + progress = manager.get_progress(conv_id, user_id) + if progress is None: + return json.dumps({"error": f"Session {conv_id} not found"}) + return json.dumps({ + "busy": progress.busy, + "elapsed_seconds": int(progress.elapsed_seconds), + "current_prompt": progress.current_prompt[:100], + "recent_text": progress.text_messages[-3:], + "recent_tools": progress.tool_calls[-5:], + "last_result": progress.last_result[:500] if not progress.busy else "", + "error": progress.error, + "pending_approval": progress.pending_approval, + }, ensure_ascii=False) + + +class InterruptConversationInput(BaseModel): + conv_id: str = Field(..., description="Conversation ID to interrupt") + + +class InterruptConversationTool(BaseTool): + name: str = "interrupt_conversation" + description: str = "Interrupt a running Claude Code task in a session." + args_schema: Type[BaseModel] = InterruptConversationInput + + def _run(self, conv_id: str) -> str: + raise NotImplementedError("Use async version") + + async def _arun(self, conv_id: str) -> str: + user_id = get_current_user() + try: + success = await manager.interrupt(conv_id, user_id) + return "Interrupted" if success else "No active task to interrupt" + except KeyError: + return f"Session {conv_id} not found" + except PermissionError as e: + return str(e) + + # Module-level tool list for easy import TOOLS = [ CreateConversationTool(), SendToConversationTool(), ListConversationsTool(), CloseConversationTool(), + SessionProgressTool(), + InterruptConversationTool(), RunCommandTool(), ShellTool(), FileReadTool(), diff --git a/tests/conftest.py b/tests/conftest.py index 5cd5ba8..4fee88a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,61 +1,43 @@ """ -Master test fixtures for PhoneWork BDD tests. +Shared test fixtures for PhoneWork tests. """ from __future__ import annotations import asyncio -import time from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, 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": []} + """Capture all calls to bot.feishu send functions.""" + captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []} async def mock_send_text(receive_id, receive_id_type, text): - captured["texts"].append({"receive_id": receive_id, "text": text}) + captured["texts"].append(text) + + async def mock_send_markdown(receive_id, receive_id_type, content): + captured["markdowns"].append(content) async def mock_send_card(receive_id, receive_id_type, card): - captured["cards"].append({"receive_id": receive_id, "card": card}) + captured["cards"].append(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}) + captured["files"].append(file_path) with patch("bot.feishu.send_text", side_effect=mock_send_text), \ + patch("bot.feishu.send_markdown", side_effect=mock_send_markdown), \ patch("bot.feishu.send_card", side_effect=mock_send_card), \ - patch("bot.feishu.send_file", side_effect=mock_send_file): + patch("bot.feishu.send_file", side_effect=mock_send_file), \ + patch("bot.handler.send_text", side_effect=mock_send_text), \ + patch("bot.handler.send_markdown", side_effect=mock_send_markdown): 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.cc_runner.run_claude", mock), \ - patch("agent.manager.run_claude", mock): - yield mock - - # ── Singleton state resets ─────────────────────────────────────────────────── @pytest.fixture(autouse=True) @@ -110,13 +92,6 @@ def reset_contextvars(): 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 @@ -127,41 +102,3 @@ def tmp_working_dir(tmp_path, monkeypatch): 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 diff --git a/tests/features/agent/passthrough.feature b/tests/features/agent/passthrough.feature deleted file mode 100644 index 36d11d3..0000000 --- a/tests/features/agent/passthrough.feature +++ /dev/null @@ -1,19 +0,0 @@ -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 diff --git a/tests/features/agent/routing.feature b/tests/features/agent/routing.feature deleted file mode 100644 index 049fa8f..0000000 --- a/tests/features/agent/routing.feature +++ /dev/null @@ -1,35 +0,0 @@ -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" diff --git a/tests/features/commands/close.feature b/tests/features/commands/close.feature deleted file mode 100644 index 26194ee..0000000 --- a/tests/features/commands/close.feature +++ /dev/null @@ -1,38 +0,0 @@ -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 diff --git a/tests/features/commands/direct_smart.feature b/tests/features/commands/direct_smart.feature deleted file mode 100644 index b55cdf8..0000000 --- a/tests/features/commands/direct_smart.feature +++ /dev/null @@ -1,27 +0,0 @@ -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" diff --git a/tests/features/commands/help.feature b/tests/features/commands/help.feature deleted file mode 100644 index 82f549a..0000000 --- a/tests/features/commands/help.feature +++ /dev/null @@ -1,25 +0,0 @@ -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 diff --git a/tests/features/commands/new.feature b/tests/features/commands/new.feature deleted file mode 100644 index 433ae28..0000000 --- a/tests/features/commands/new.feature +++ /dev/null @@ -1,37 +0,0 @@ -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 diff --git a/tests/features/commands/nodes.feature b/tests/features/commands/nodes.feature deleted file mode 100644 index be78448..0000000 --- a/tests/features/commands/nodes.feature +++ /dev/null @@ -1,13 +0,0 @@ -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" diff --git a/tests/features/commands/perm.feature b/tests/features/commands/perm.feature deleted file mode 100644 index 82b53ef..0000000 --- a/tests/features/commands/perm.feature +++ /dev/null @@ -1,70 +0,0 @@ -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 "edit" - And reply contains "plan" - - Scenario: Set active session to edit mode - Given user has session "sess01" in "/tmp/proj1" - And active session is "sess01" - When user sends "/perm edit" - Then reply contains "edit" - 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 edit" - 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 edit sess01" - Then reply contains "another user" - - Scenario: New session with --perm edit - When user sends "/new myproject --perm edit" - Then reply contains "edit" - 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" diff --git a/tests/features/commands/remind.feature b/tests/features/commands/remind.feature deleted file mode 100644 index a34fe4f..0000000 --- a/tests/features/commands/remind.feature +++ /dev/null @@ -1,33 +0,0 @@ -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 diff --git a/tests/features/commands/shell.feature b/tests/features/commands/shell.feature deleted file mode 100644 index 60a8467..0000000 --- a/tests/features/commands/shell.feature +++ /dev/null @@ -1,22 +0,0 @@ -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" diff --git a/tests/features/commands/status.feature b/tests/features/commands/status.feature deleted file mode 100644 index 3e04b66..0000000 --- a/tests/features/commands/status.feature +++ /dev/null @@ -1,40 +0,0 @@ -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 diff --git a/tests/features/commands/switch.feature b/tests/features/commands/switch.feature deleted file mode 100644 index bc93b12..0000000 --- a/tests/features/commands/switch.feature +++ /dev/null @@ -1,30 +0,0 @@ -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" diff --git a/tests/features/commands/tasks.feature b/tests/features/commands/tasks.feature deleted file mode 100644 index ec1bb5d..0000000 --- a/tests/features/commands/tasks.feature +++ /dev/null @@ -1,27 +0,0 @@ -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 "❌" diff --git a/tests/step_defs/__init__.py b/tests/step_defs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/step_defs/common_steps.py b/tests/step_defs/common_steps.py deleted file mode 100644 index c1d202b..0000000 --- a/tests/step_defs/common_steps.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -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" diff --git a/tests/step_defs/test_agent.py b/tests/step_defs/test_agent.py deleted file mode 100644 index 4031c55..0000000 --- a/tests/step_defs/test_agent.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -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)}" diff --git a/tests/step_defs/test_commands.py b/tests/step_defs/test_commands.py deleted file mode 100644 index c9cc5b0..0000000 --- a/tests/step_defs/test_commands.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -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 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..f06954f --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,384 @@ +"""Tests for bot slash commands (replaces BDD feature tests). + +Covers: //help, //new, //close, //switch, //status, //perm, + //direct, //smart, //shell, //remind, //tasks, //stop, //progress, //nodes +""" +from __future__ import annotations + +import time + +import pytest + +from agent.manager import manager, Session +from orchestrator.agent import agent +from orchestrator.tools import set_current_user, set_current_chat + + +def _setup_user(user_id="user_abc123", chat_id=None): + set_current_user(user_id) + if chat_id: + set_current_chat(chat_id) + + +def _add_session(conv_id, cwd="/tmp/proj", user_id="user_abc123", activate=False): + session = Session(conv_id=conv_id, cwd=cwd, owner_id=user_id) + manager._sessions[conv_id] = session + if activate: + agent._active_conv[user_id] = conv_id + return session + + +# ── //help ────────────────────────────────────────────────────────────────── + +class TestHelp: + @pytest.mark.asyncio + async def test_help_lists_commands(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//help") + for cmd in ("//new", "//status", "//close", "//switch", "//perm", + "//stop", "//progress", "//direct", "//smart", "//shell"): + assert cmd in reply, f"Missing {cmd} in help" + + @pytest.mark.asyncio + async def test_h_alias(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//h") + assert "//new" in reply + + @pytest.mark.asyncio + async def test_unknown_command_returns_none(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//unknown_xyz") + assert reply is None + + +# ── //new ─────────────────────────────────────────────────────────────────── + +class TestNew: + @pytest.mark.asyncio + async def test_no_args_shows_usage(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//new") + assert "Usage" in reply + + @pytest.mark.asyncio + async def test_creates_session(self, tmp_working_dir): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//new myproject") + assert "myproject" in reply + assert len(manager.list_sessions(user_id="user_abc123")) == 1 + + @pytest.mark.asyncio + async def test_path_traversal_blocked(self, tmp_working_dir): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//new ../../etc") + assert "Error" in reply + assert len(manager.list_sessions(user_id="user_abc123")) == 0 + + @pytest.mark.asyncio + async def test_with_perm_flag(self, tmp_working_dir): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//new myproject --perm plan") + sessions = manager.list_sessions(user_id="user_abc123") + assert len(sessions) == 1 + assert sessions[0]["permission_mode"] == "plan" + + @pytest.mark.asyncio + async def test_sends_card_when_chat_set(self, tmp_working_dir, feishu_calls): + from bot.commands import handle_command + _setup_user(chat_id="chat1") + reply = await handle_command("user_abc123", "//new myproject") + assert reply == "" # card was sent instead + assert len(feishu_calls["cards"]) >= 1 + + +# ── //close ───────────────────────────────────────────────────────────────── + +class TestClose: + @pytest.mark.asyncio + async def test_no_sessions(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//close") + assert "No sessions" in reply or "No active" in reply + + @pytest.mark.asyncio + async def test_close_active(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//close") + assert "Closed" in reply + assert len(manager.list_sessions(user_id="user_abc123")) == 0 + + @pytest.mark.asyncio + async def test_close_by_number(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + _add_session("s2") + reply = await handle_command("user_abc123", "//close 1") + assert "Closed" in reply + assert len(manager.list_sessions(user_id="user_abc123")) == 1 + + @pytest.mark.asyncio + async def test_invalid_number(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + reply = await handle_command("user_abc123", "//close 9") + assert "Invalid" in reply + + @pytest.mark.asyncio + async def test_cannot_close_other_user(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", user_id="other_user") + reply = await handle_command("user_abc123", "//close s1") + assert "another user" in reply or "not found" in reply.lower() + + +# ── //switch ──────────────────────────────────────────────────────────────── + +class TestSwitch: + @pytest.mark.asyncio + async def test_no_sessions(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//switch 1") + assert "No sessions" in reply + + @pytest.mark.asyncio + async def test_valid_switch(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + _add_session("s2") + reply = await handle_command("user_abc123", "//switch 2") + assert "Switched" in reply + assert agent._active_conv["user_abc123"] == "s2" + + @pytest.mark.asyncio + async def test_out_of_range(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + reply = await handle_command("user_abc123", "//switch 5") + assert "Invalid" in reply + + @pytest.mark.asyncio + async def test_non_numeric(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + reply = await handle_command("user_abc123", "//switch abc") + assert "Invalid" in reply + + +# ── //status ──────────────────────────────────────────────────────────────── + +class TestStatus: + @pytest.mark.asyncio + async def test_no_sessions(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//status") + assert "No active sessions" in reply + + @pytest.mark.asyncio + async def test_shows_sessions(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1") + _add_session("s2") + reply = await handle_command("user_abc123", "//status") + assert "s1" in reply + assert "s2" in reply + + @pytest.mark.asyncio + async def test_shows_active_marker(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//status") + assert "→" in reply + + +# ── //perm ────────────────────────────────────────────────────────────────── + +class TestPerm: + @pytest.mark.asyncio + async def test_no_args_shows_usage(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//perm") + assert "Usage" in reply + + @pytest.mark.asyncio + async def test_set_edit(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//perm edit") + assert "edit" in reply + assert manager._sessions["s1"].permission_mode == "acceptEdits" + + @pytest.mark.asyncio + async def test_set_plan(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//perm plan") + assert "plan" in reply + assert manager._sessions["s1"].permission_mode == "plan" + + @pytest.mark.asyncio + async def test_set_auto(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//perm auto") + assert "auto" in reply + assert manager._sessions["s1"].permission_mode == "dontAsk" + + @pytest.mark.asyncio + async def test_unknown_mode(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//perm xyz") + assert "Unknown" in reply + + @pytest.mark.asyncio + async def test_no_active_session(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//perm edit") + assert "No active session" in reply + + +# ── //direct + //smart ────────────────────────────────────────────────────── + +class TestDirectSmart: + @pytest.mark.asyncio + async def test_direct_requires_session(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//direct") + assert "No active session" in reply + + @pytest.mark.asyncio + async def test_direct_enables_passthrough(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + reply = await handle_command("user_abc123", "//direct") + assert "Direct mode ON" in reply + assert agent._passthrough["user_abc123"] is True + + @pytest.mark.asyncio + async def test_smart_disables_passthrough(self): + from bot.commands import handle_command + _setup_user() + _add_session("s1", activate=True) + agent._passthrough["user_abc123"] = True + reply = await handle_command("user_abc123", "//smart") + assert "Smart mode ON" in reply + assert agent._passthrough["user_abc123"] is False + + +# ── //shell ───────────────────────────────────────────────────────────────── + +class TestShell: + @pytest.mark.asyncio + async def test_no_args_shows_usage(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//shell") + assert "Usage" in reply + + @pytest.mark.asyncio + async def test_echo(self, tmp_working_dir): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//shell echo hello") + assert "hello" in reply + assert "exit code: 0" in reply + + @pytest.mark.asyncio + async def test_blocked_dangerous(self, tmp_working_dir): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//shell rm -rf /") + assert "Blocked" in reply + + +# ── //remind ──────────────────────────────────────────────────────────────── + +class TestRemind: + @pytest.mark.asyncio + async def test_no_args(self): + from bot.commands import handle_command + _setup_user(chat_id="chat1") + reply = await handle_command("user_abc123", "//remind") + assert "Usage" in reply + + @pytest.mark.asyncio + async def test_missing_message(self): + from bot.commands import handle_command + _setup_user(chat_id="chat1") + reply = await handle_command("user_abc123", "//remind 10m") + assert "Usage" in reply + + @pytest.mark.asyncio + async def test_valid_reminder(self): + from bot.commands import handle_command + from agent.scheduler import scheduler + _setup_user(chat_id="chat1") + reply = await handle_command("user_abc123", "//remind 30s check build") + assert "Reminder" in reply + assert len(scheduler._jobs) == 1 + + +# ── //tasks ───────────────────────────────────────────────────────────────── + +class TestTasks: + @pytest.mark.asyncio + async def test_no_tasks(self): + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//tasks") + assert "No background tasks" in reply + + @pytest.mark.asyncio + async def test_shows_running_task(self): + from bot.commands import handle_command + from agent.task_runner import task_runner, BackgroundTask, TaskStatus + _setup_user() + task_runner._tasks["t1"] = BackgroundTask( + task_id="t1", description="fix bug", started_at=time.time(), + status=TaskStatus.RUNNING, + ) + reply = await handle_command("user_abc123", "//tasks") + assert "t1" in reply + assert "⏳" in reply + + +# ── //nodes ───────────────────────────────────────────────────────────────── + +class TestNodes: + @pytest.mark.asyncio + async def test_nodes_outside_router_mode(self, monkeypatch): + import config + monkeypatch.setattr(config, "ROUTER_MODE", False) + from bot.commands import handle_command + _setup_user() + reply = await handle_command("user_abc123", "//nodes") + assert "Not in router mode" in reply diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py new file mode 100644 index 0000000..487c71a --- /dev/null +++ b/tests/test_sdk_migration.py @@ -0,0 +1,816 @@ +"""Unit tests for the SDK migration (secretary model). + +Tests cover: +- SDKSession lifecycle, message buffering, get_progress, approval +- sdk_hooks audit + deny +- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve) +- audit.py new functions (log_tool_use, log_permission_decision) +- bot/commands.py new commands (//stop, //progress, //perm auto) +- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool) +- bot/handler.py text approval fallback +""" + +from __future__ import annotations + +import asyncio +import json +import time +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_audit_dir(tmp_path): + """Redirect audit logs to a temp directory.""" + import agent.audit as audit_mod + original = audit_mod.AUDIT_DIR + audit_mod.AUDIT_DIR = tmp_path / "audit" + yield tmp_path / "audit" + audit_mod.AUDIT_DIR = original + + +@pytest.fixture +def mock_sdk_client(): + """Create a mock ClaudeSDKClient that yields controllable messages.""" + client = AsyncMock() + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.query = AsyncMock() + client.interrupt = AsyncMock() + client.set_permission_mode = AsyncMock() + return client + + +@pytest.fixture +def mock_feishu(): + """Mock all Feishu send functions.""" + captured = {"texts": [], "cards": [], "markdowns": []} + + async def _send_text(rid, rtype, text): + captured["texts"].append(text) + + async def _send_card(rid, rtype, card): + captured["cards"].append(card) + + async def _send_markdown(rid, rtype, content): + captured["markdowns"].append(content) + + with patch("bot.feishu.send_text", side_effect=_send_text), \ + patch("bot.feishu.send_card", side_effect=_send_card), \ + patch("bot.feishu.send_markdown", side_effect=_send_markdown), \ + patch("bot.handler.send_text", side_effect=_send_text), \ + patch("bot.handler.send_markdown", side_effect=_send_markdown): + yield captured + + +# =========================================================================== +# 1. SDKSession unit tests +# =========================================================================== + + +class TestSDKSessionProgress: + """Test SDKSession.get_progress() with buffered messages.""" + + def test_initial_progress_is_idle(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + p = s.get_progress() + assert p.busy is False + assert p.current_prompt == "" + assert p.text_messages == [] + assert p.tool_calls == [] + + def test_progress_after_manual_state_change(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + s._busy = True + s._current_prompt = "write hello.py" + s._started_at = time.time() - 5 + s._text_buffer = ["I'll create the file", "Done"] + s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"] + p = s.get_progress() + assert p.busy is True + assert p.current_prompt == "write hello.py" + assert p.elapsed_seconds >= 4 + assert len(p.text_messages) == 2 + assert len(p.tool_calls) == 2 + + def test_buffer_limits(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + for i in range(30): + s._text_buffer.append(f"text-{i}") + if len(s._text_buffer) > s.MAX_BUFFER_TEXTS: + s._text_buffer.pop(0) + assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS + assert s._text_buffer[0] == "text-10" + + def test_progress_pending_approval(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + s._pending_approval_desc = "Bash: `rm -rf /tmp/test`" + p = s.get_progress() + assert p.pending_approval == "Bash: `rm -rf /tmp/test`" + + +class TestSDKSessionApproval: + """Test the approval mechanism.""" + + @pytest.mark.asyncio + async def test_approve_resolves_future(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + loop = asyncio.get_running_loop() + s._pending_approval = loop.create_future() + await s.approve(True) + assert s._pending_approval.result() is True + + @pytest.mark.asyncio + async def test_approve_deny(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + loop = asyncio.get_running_loop() + s._pending_approval = loop.create_future() + await s.approve(False) + assert s._pending_approval.result() is False + + @pytest.mark.asyncio + async def test_approve_no_pending_is_noop(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + # Should not raise + await s.approve(True) + + +class TestSDKSessionClose: + """Test clean shutdown.""" + + @pytest.mark.asyncio + async def test_close_without_start(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + # Should not raise + await s.close() + assert s.client is None + + @pytest.mark.asyncio + async def test_close_disconnects_client(self, mock_sdk_client): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + s.client = mock_sdk_client + s._message_loop_task = None + await s.close() + mock_sdk_client.disconnect.assert_awaited_once() + assert s.client is None + + +class TestSDKSessionSend: + """Test send() and send_and_wait() with mocked client.""" + + @pytest.mark.asyncio + async def test_send_returns_immediately(self): + from agent.sdk_session import SDKSession + + s = SDKSession("c1", "/tmp", "u1", chat_id="chat1") + + # Provide a mock client that has receive_messages yielding nothing + mock_client = AsyncMock() + mock_client.query = AsyncMock() + + async def _empty_messages(): + return + yield # make it an async generator + + mock_client.receive_messages = _empty_messages + s.client = mock_client + + result = await s.send("hello") + assert "已开始执行" in result + assert s._busy is True + assert s._current_prompt == "hello" + + # Cleanup + if s._message_loop_task: + s._message_loop_task.cancel() + try: + await s._message_loop_task + except asyncio.CancelledError: + pass + + +class TestSDKSessionFormatSummary: + """Test _format_tool_summary.""" + + def test_bash_summary(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + result = s._format_tool_summary("Bash", {"command": "ls -la"}) + assert "`ls -la`" in result + + def test_edit_summary(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"}) + assert "test.py" in result + + def test_other_summary_truncated(self): + from agent.sdk_session import SDKSession + s = SDKSession("c1", "/tmp", "u1") + result = s._format_tool_summary("CustomTool", {"key": "x" * 500}) + assert len(result) <= 200 + + +# =========================================================================== +# 2. sdk_hooks tests +# =========================================================================== + + +class TestSDKHooks: + + @pytest.mark.asyncio + async def test_audit_hook_logs(self, tmp_audit_dir): + from agent.sdk_hooks import audit_hook + + input_data = { + "session_id": "test-session", + "tool_name": "Bash", + "tool_input": {"command": "echo hello"}, + "tool_response": "hello\n", + } + result = await audit_hook(input_data, "tu-1", {"signal": None}) + assert result == {} + + # Check JSONL was written + log_file = tmp_audit_dir / "test-session.jsonl" + assert log_file.exists() + entry = json.loads(log_file.read_text().strip()) + assert entry["type"] == "tool_use" + assert entry["tool_name"] == "Bash" + + @pytest.mark.asyncio + async def test_deny_dangerous_rm_rf(self): + from agent.sdk_hooks import deny_dangerous_hook + + input_data = { + "tool_name": "Bash", + "tool_input": {"command": "rm -rf /"}, + } + result = await deny_dangerous_hook(input_data, None, {"signal": None}) + assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny" + + @pytest.mark.asyncio + async def test_deny_allows_safe_commands(self): + from agent.sdk_hooks import deny_dangerous_hook + + input_data = { + "tool_name": "Bash", + "tool_input": {"command": "ls -la /tmp"}, + } + result = await deny_dangerous_hook(input_data, None, {"signal": None}) + assert result == {} + + @pytest.mark.asyncio + async def test_deny_ignores_non_bash(self): + from agent.sdk_hooks import deny_dangerous_hook + + input_data = { + "tool_name": "Edit", + "tool_input": {"file_path": "/etc/passwd"}, + } + result = await deny_dangerous_hook(input_data, None, {"signal": None}) + assert result == {} + + def test_build_hooks_returns_expected_structure(self): + from agent.sdk_hooks import build_hooks + + hooks = build_hooks("test-conv") + assert "PostToolUse" in hooks + assert "PreToolUse" in hooks + assert len(hooks["PostToolUse"]) == 1 + assert len(hooks["PreToolUse"]) == 1 + + +# =========================================================================== +# 3. manager tests (new methods) +# =========================================================================== + + +class TestSessionManagerNew: + + @pytest.fixture(autouse=True) + def _reset(self): + from agent.manager import manager + manager._sessions.clear() + yield + manager._sessions.clear() + + @pytest.mark.asyncio + async def test_create_session_no_cc_timeout(self): + from agent.manager import manager, Session + s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1") + assert s.conv_id == "c1" + assert s.chat_id == "chat1" + assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict() + + @pytest.mark.asyncio + async def test_get_progress_no_session(self): + from agent.manager import manager + result = manager.get_progress("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_get_progress_no_sdk_session(self): + from agent.manager import manager + await manager.create("c1", "/tmp/test", owner_id="u1") + p = manager.get_progress("c1", user_id="u1") + assert p is not None + assert p.busy is False + + @pytest.mark.asyncio + async def test_interrupt_no_sdk_session(self): + from agent.manager import manager + await manager.create("c1", "/tmp/test", owner_id="u1") + result = await manager.interrupt("c1", user_id="u1") + assert result is False + + @pytest.mark.asyncio + async def test_approve_no_sdk_session(self): + from agent.manager import manager + await manager.create("c1", "/tmp/test", owner_id="u1") + # Should not raise + await manager.approve("c1", True) + + @pytest.mark.asyncio + async def test_close_with_sdk_session(self): + from agent.manager import manager + from agent.sdk_session import SDKSession + await manager.create("c1", "/tmp/test", owner_id="u1") + mock_sdk = MagicMock(spec=SDKSession) + mock_sdk.close = AsyncMock() + manager._sessions["c1"].sdk_session = mock_sdk + + result = await manager.close("c1", user_id="u1") + assert result is True + mock_sdk.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_set_permission_mode_with_sdk_session(self): + from agent.manager import manager + from agent.sdk_session import SDKSession + await manager.create("c1", "/tmp/test", owner_id="u1") + + mock_sdk = MagicMock(spec=SDKSession) + mock_sdk.set_permission_mode = AsyncMock() + manager._sessions["c1"].sdk_session = mock_sdk + + manager.set_permission_mode("c1", "acceptEdits", user_id="u1") + assert manager._sessions["c1"].permission_mode == "acceptEdits" + + def test_list_sessions_includes_busy(self): + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + mock_sdk = MagicMock(spec=SDKSession) + mock_sdk._busy = True + session.sdk_session = mock_sdk + manager._sessions["c1"] = session + result = manager.list_sessions() + assert result[0]["busy"] is True + + def test_session_from_dict_strips_old_fields(self): + from agent.manager import Session + old_data = { + "conv_id": "c1", + "cwd": "/tmp", + "owner_id": "u1", + "cc_session_id": "old-uuid", + "started": True, + "cc_timeout": 300.0, + "last_activity": 0.0, + "idle_timeout": 1800, + "permission_mode": "default", + } + s = Session.from_dict(old_data) + assert s.conv_id == "c1" + assert not hasattr(s, "cc_session_id") + + def test_session_to_dict_excludes_sdk_session(self): + from agent.manager import Session + s = Session(conv_id="c1", cwd="/tmp") + s.sdk_session = MagicMock() + d = s.to_dict() + assert "sdk_session" not in d + + +# =========================================================================== +# 4. audit tests (new functions) +# =========================================================================== + + +class TestAuditNewFunctions: + + def test_log_tool_use(self, tmp_audit_dir): + from agent.audit import log_tool_use + + log_tool_use( + session_id="s1", + tool_name="Bash", + tool_input={"command": "echo hello"}, + tool_response="hello\n", + ) + + log_file = tmp_audit_dir / "s1.jsonl" + assert log_file.exists() + entry = json.loads(log_file.read_text().strip()) + assert entry["type"] == "tool_use" + assert entry["tool_name"] == "Bash" + + def test_log_permission_decision_approved(self, tmp_audit_dir): + from agent.audit import log_permission_decision + + log_permission_decision( + conv_id="c1", + tool_name="Bash", + tool_input={"command": "rm test.txt"}, + approved=True, + ) + + log_file = tmp_audit_dir / "c1.jsonl" + assert log_file.exists() + entry = json.loads(log_file.read_text().strip()) + assert entry["type"] == "permission_decision" + assert entry["approved"] is True + + def test_log_permission_decision_denied(self, tmp_audit_dir): + from agent.audit import log_permission_decision + + log_permission_decision( + conv_id="c1", + tool_name="Write", + tool_input={"file_path": "/etc/passwd"}, + approved=False, + ) + + log_file = tmp_audit_dir / "c1.jsonl" + entry = json.loads(log_file.read_text().strip()) + assert entry["approved"] is False + + +# =========================================================================== +# 5. bot/commands.py new commands +# =========================================================================== + + +class TestNewCommands: + + @pytest.fixture(autouse=True) + def _reset(self): + from agent.manager import manager + from orchestrator.agent import agent + from orchestrator.tools import set_current_user, set_current_chat + manager._sessions.clear() + agent._active_conv.clear() + agent._passthrough.clear() + set_current_user(None) + set_current_chat(None) + yield + manager._sessions.clear() + agent._active_conv.clear() + + @pytest.mark.asyncio + async def test_stop_no_active_session(self): + from bot.commands import handle_command + result = await handle_command("u1", "//stop") + assert "No active session" in result + + @pytest.mark.asyncio + async def test_stop_with_session_no_sdk(self): + from bot.commands import handle_command + from agent.manager import manager, Session + from orchestrator.agent import agent + from orchestrator.tools import set_current_user + + set_current_user("u1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + result = await handle_command("u1", "//stop") + assert "No active task" in result + + @pytest.mark.asyncio + async def test_progress_no_session(self): + from bot.commands import handle_command + result = await handle_command("u1", "//progress") + assert "No active session" in result + + @pytest.mark.asyncio + async def test_progress_idle_session(self): + from bot.commands import handle_command + from agent.manager import manager, Session + from orchestrator.agent import agent + from orchestrator.tools import set_current_user + + set_current_user("u1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + result = await handle_command("u1", "//progress") + assert "空闲" in result + + @pytest.mark.asyncio + async def test_progress_busy_session(self): + from bot.commands import handle_command + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + from orchestrator.agent import agent + from orchestrator.tools import set_current_user + + set_current_user("u1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + sdk = SDKSession("c1", "/tmp", "u1") + sdk._busy = True + sdk._started_at = time.time() - 10 + sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"] + session.sdk_session = sdk + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + result = await handle_command("u1", "//progress") + assert "执行中" in result + assert "Bash" in result + + @pytest.mark.asyncio + async def test_progress_with_pending_approval(self): + from bot.commands import handle_command + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + from orchestrator.agent import agent + from orchestrator.tools import set_current_user + + set_current_user("u1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + sdk = SDKSession("c1", "/tmp", "u1") + sdk._busy = True + sdk._started_at = time.time() - 5 + sdk._pending_approval_desc = "Bash: `rm test`" + session.sdk_session = sdk + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + result = await handle_command("u1", "//progress") + assert "审批" in result + + @pytest.mark.asyncio + async def test_perm_auto_alias(self): + from bot.commands import handle_command + from agent.manager import manager, Session + from orchestrator.agent import agent + from orchestrator.tools import set_current_user + + set_current_user("u1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + result = await handle_command("u1", "//perm auto") + assert "auto" in result + assert manager._sessions["c1"].permission_mode == "dontAsk" + + +# =========================================================================== +# 6. orchestrator/tools.py new tools +# =========================================================================== + + +class TestNewTools: + + @pytest.fixture(autouse=True) + def _reset(self): + from agent.manager import manager + from orchestrator.tools import set_current_user, set_current_chat + manager._sessions.clear() + set_current_user("u1") + set_current_chat("chat1") + yield + manager._sessions.clear() + set_current_user(None) + set_current_chat(None) + + @pytest.mark.asyncio + async def test_session_progress_not_found(self): + from orchestrator.tools import SessionProgressTool + tool = SessionProgressTool() + result = await tool._arun("nonexistent") + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_session_progress_idle(self): + from orchestrator.tools import SessionProgressTool + from agent.manager import manager, Session + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + + tool = SessionProgressTool() + result = await tool._arun("c1") + data = json.loads(result) + assert data["busy"] is False + + @pytest.mark.asyncio + async def test_session_progress_busy(self): + from orchestrator.tools import SessionProgressTool + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + sdk = SDKSession("c1", "/tmp", "u1") + sdk._busy = True + sdk._started_at = time.time() - 30 + sdk._current_prompt = "fix the bug" + sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"] + session.sdk_session = sdk + manager._sessions["c1"] = session + + tool = SessionProgressTool() + result = await tool._arun("c1") + data = json.loads(result) + assert data["busy"] is True + assert data["elapsed_seconds"] >= 29 + assert "Edit" in str(data["recent_tools"]) + + @pytest.mark.asyncio + async def test_interrupt_not_found(self): + from orchestrator.tools import InterruptConversationTool + tool = InterruptConversationTool() + result = await tool._arun("nonexistent") + assert "not found" in result + + @pytest.mark.asyncio + async def test_interrupt_no_active_task(self): + from orchestrator.tools import InterruptConversationTool + from agent.manager import manager, Session + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + + tool = InterruptConversationTool() + result = await tool._arun("c1") + assert "No active task" in result + + +# =========================================================================== +# 7. bot/handler.py text approval fallback +# =========================================================================== + + +class TestTextApprovalFallback: + + @pytest.fixture(autouse=True) + def _reset(self): + from agent.manager import manager + from orchestrator.agent import agent + from orchestrator.tools import set_current_chat + manager._sessions.clear() + agent._active_conv.clear() + set_current_chat(None) + yield + manager._sessions.clear() + agent._active_conv.clear() + + @pytest.mark.asyncio + async def test_y_resolves_pending_approval(self, mock_feishu): + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + from orchestrator.agent import agent + from orchestrator.tools import set_current_chat + + set_current_chat("chat1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1") + loop = asyncio.get_running_loop() + sdk._pending_approval = loop.create_future() + session.sdk_session = sdk + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + from bot.handler import _process_message + await _process_message("u1", "chat1", "y") + + assert sdk._pending_approval.done() + assert sdk._pending_approval.result() is True + # handler calls send_text which is mocked separately from send_markdown + assert any("批准" in t for t in mock_feishu["texts"]) + + @pytest.mark.asyncio + async def test_n_denies_pending_approval(self, mock_feishu): + from agent.manager import manager, Session + from agent.sdk_session import SDKSession + from orchestrator.agent import agent + from orchestrator.tools import set_current_chat + + set_current_chat("chat1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1") + loop = asyncio.get_running_loop() + sdk._pending_approval = loop.create_future() + session.sdk_session = sdk + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + from bot.handler import _process_message + await _process_message("u1", "chat1", "n") + + assert sdk._pending_approval.done() + assert sdk._pending_approval.result() is False + assert any("拒绝" in t for t in mock_feishu["texts"]) + + @pytest.mark.asyncio + async def test_y_without_pending_falls_through(self, mock_feishu): + """If there's no pending approval, 'y' should not be consumed.""" + from agent.manager import manager, Session + from orchestrator.agent import agent + from orchestrator.tools import set_current_chat + + set_current_chat("chat1") + session = Session(conv_id="c1", cwd="/tmp", owner_id="u1") + manager._sessions["c1"] = session + agent._active_conv["u1"] = "c1" + + from bot.handler import _process_message + # Patch agent.run to avoid actual LLM call + with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"): + await _process_message("u1", "chat1", "y") + + # Should not have consumed as approval + assert not any("批准" in t for t in mock_feishu["markdowns"]) + + +# =========================================================================== +# 8. bot/feishu.py build_approval_card +# =========================================================================== + + +class TestBuildApprovalCard: + + def test_card_structure(self): + from bot.feishu import build_approval_card + card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60) + + assert card["schema"] == "2.0" + assert "权限审批" in card["header"]["title"]["content"] + + body_elements = card["body"]["elements"] + # Should have markdown, action, and note elements + tags = [e["tag"] for e in body_elements] + assert "markdown" in tags + assert "action" in tags + assert "note" in tags + + # Action should have 2 buttons + action_el = next(e for e in body_elements if e["tag"] == "action") + assert len(action_el["actions"]) == 2 + # First button should be approve + assert action_el["actions"][0]["value"]["action"] == "approve" + assert action_el["actions"][0]["value"]["conv_id"] == "c1" + # Second button should be deny + assert action_el["actions"][1]["value"]["action"] == "deny" + + +# =========================================================================== +# 9. Permission mode constants +# =========================================================================== + + +class TestPermissionModes: + + def test_valid_modes_includes_dontask(self): + from agent.sdk_session import VALID_PERMISSION_MODES + assert "dontAsk" in VALID_PERMISSION_MODES + + def test_perm_aliases_has_auto(self): + from bot.commands import _PERM_ALIASES + assert _PERM_ALIASES["auto"] == "dontAsk" + + def test_perm_labels_has_dontask(self): + from bot.commands import _PERM_LABELS + assert _PERM_LABELS["dontAsk"] == "auto" + + +# =========================================================================== +# 10. Config +# =========================================================================== + + +class TestConfig: + + def test_sdk_approval_timeout_exists(self): + from config import SDK_APPROVAL_TIMEOUT + assert isinstance(SDK_APPROVAL_TIMEOUT, int) + assert SDK_APPROVAL_TIMEOUT > 0