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
This commit is contained in:
Yuyao Huang 2026-04-01 12:51:00 +08:00
parent ba1b5b76c6
commit eac90941ef
34 changed files with 2375 additions and 965 deletions

View File

@ -7,6 +7,7 @@
| Project architecture, deployment, bot commands | `README.md` | | Project architecture, deployment, bot commands | `README.md` |
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` | | Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` | | 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/` | | Feishu card / markdown formatting | `docs/feishu/` |
## Claude Agent SDK ## 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 bot/ Feishu event handling, commands, message sending
orchestrator/ LangChain agent + tools (session management, shell, files, web) 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) router/ Multi-host routing (public VPS side)
host_client/ Host client (behind NAT, connects to router) host_client/ Host client (behind NAT, connects to router)
shared/ Wire protocol for router ↔ host communication shared/ Wire protocol for router ↔ host communication

View File

@ -58,6 +58,61 @@ def log_interaction(
logger.exception("Failed to log audit entry for session %s", conv_id) 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]: def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
"""Read the audit log for a session.""" """Read the audit log for a session."""
log_file = AUDIT_DIR / f"{conv_id}.jsonl" log_file = AUDIT_DIR / f"{conv_id}.jsonl"

View File

@ -5,18 +5,20 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import uuid
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from pathlib import Path 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.sdk_session import (
from agent.audit import log_interaction SDKSession,
SessionProgress,
DEFAULT_PERMISSION_MODE,
VALID_PERMISSION_MODES,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_IDLE_TIMEOUT = 30 * 60 DEFAULT_IDLE_TIMEOUT = 30 * 60
DEFAULT_CC_TIMEOUT = 300.0
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json" PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
@ -25,21 +27,27 @@ class Session:
conv_id: str conv_id: str
cwd: str cwd: str
owner_id: str = "" owner_id: str = ""
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
last_activity: float = 0.0 last_activity: float = 0.0
started: bool = False
idle_timeout: int = DEFAULT_IDLE_TIMEOUT idle_timeout: int = DEFAULT_IDLE_TIMEOUT
cc_timeout: float = DEFAULT_CC_TIMEOUT
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE) 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: def touch(self) -> None:
self.last_activity = asyncio.get_event_loop().time() self.last_activity = asyncio.get_event_loop().time()
def to_dict(self) -> dict: def to_dict(self) -> dict:
return asdict(self) d = asdict(self)
d.pop("sdk_session", None)
return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Session": 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) return cls(**data)
@ -60,6 +68,9 @@ class SessionManager:
if self._reaper_task: if self._reaper_task:
self._reaper_task.cancel() self._reaper_task.cancel()
async with self._lock: async with self._lock:
for session in self._sessions.values():
if session.sdk_session:
await session.sdk_session.close()
self._sessions.clear() self._sessions.clear()
if PERSISTENCE_FILE.exists(): if PERSISTENCE_FILE.exists():
PERSISTENCE_FILE.unlink() PERSISTENCE_FILE.unlink()
@ -70,8 +81,8 @@ class SessionManager:
working_dir: str, working_dir: str,
owner_id: str = "", owner_id: str = "",
idle_timeout: int = DEFAULT_IDLE_TIMEOUT, idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
cc_timeout: float = DEFAULT_CC_TIMEOUT,
permission_mode: str = DEFAULT_PERMISSION_MODE, permission_mode: str = DEFAULT_PERMISSION_MODE,
chat_id: str | None = None,
) -> Session: ) -> Session:
async with self._lock: async with self._lock:
session = Session( session = Session(
@ -79,103 +90,79 @@ class SessionManager:
cwd=working_dir, cwd=working_dir,
owner_id=owner_id, owner_id=owner_id,
idle_timeout=idle_timeout, idle_timeout=idle_timeout,
cc_timeout=cc_timeout,
permission_mode=permission_mode, permission_mode=permission_mode,
chat_id=chat_id,
) )
self._sessions[conv_id] = session self._sessions[conv_id] = session
self._save() self._save()
logger.info( logger.info(
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)", "Created session %s (owner=...%s) in %s (idle=%ds, perm=%s)",
conv_id, owner_id[-8:] if owner_id else "-", working_dir, conv_id, owner_id[-8:] if owner_id else "-", working_dir,
idle_timeout, cc_timeout, permission_mode, idle_timeout, permission_mode,
) )
return session return session
async def send(self, conv_id: str, message: str, user_id: Optional[str] = None, direct: bool = False) -> str: # --- Secretary model: async send (returns immediately) ---
async with self._lock:
session = self._sessions.get(conv_id) async def send_message(
if session is None: self, conv_id: str, message: str,
raise KeyError(f"No session for conv_id={conv_id!r}") user_id: Optional[str] = None, chat_id: Optional[str] = None,
if session.owner_id and user_id and session.owner_id != user_id: ) -> str:
raise PermissionError(f"Session {conv_id} belongs to another user") """Send a message to the session. Returns immediately; result pushed to Feishu on completion."""
session = self._get_session(conv_id, user_id)
session.touch() session.touch()
cwd = session.cwd self._ensure_sdk_session(session, chat_id)
cc_session_id = session.cc_session_id return await session.sdk_session.send(message, chat_id)
cc_timeout = session.cc_timeout
permission_mode = session.permission_mode
first_message = not session.started
if first_message:
session.started = True
self._save()
if not direct and cc_timeout > 60: async def send_and_wait(
from agent.task_runner import task_runner self, conv_id: str, message: str,
from orchestrator.tools import get_current_chat, set_current_chat, set_current_user 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)
# --- Kept for backward compatibility (used by bot/commands.py _cmd_new) ---
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() chat_id = get_current_chat()
return await self.send_message(conv_id, message, user_id=user_id, chat_id=chat_id)
async def run_task(): # --- 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,
)
log_interaction(
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
return output
async def on_task_complete(task) -> None: def get_progress(self, conv_id: str, user_id: Optional[str] = None) -> SessionProgress | None:
if not chat_id or not user_id or not task.result: """Query session progress. Primary interface for the secretary AI."""
return session = self._sessions.get(conv_id)
set_current_user(user_id) if not session:
set_current_chat(chat_id) return None
from orchestrator.agent import agent if session.owner_id and user_id and session.owner_id != user_id:
follow_up = ( return None
f"CC task completed. Output:\n{task.result}\n\n" if session.sdk_session:
f"Original request was: {message}\n\n" return session.sdk_session.get_progress()
"If the user asked you to send a file, use send_file now. " return SessionProgress()
"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)
task_id = await task_runner.submit( async def interrupt(self, conv_id: str, user_id: Optional[str] = None) -> bool:
run_task(), """Interrupt the currently running task in a session."""
description=f"CC session {conv_id}: {message[:50]}", session = self._get_session(conv_id, user_id)
notify_chat_id=chat_id, if session.sdk_session:
user_id=user_id, await session.sdk_session.interrupt()
on_complete=on_task_complete, return True
) return False
return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done."
output = await run_claude( async def approve(self, conv_id: str, approved: bool) -> None:
message, """Resolve a pending tool approval for a session."""
cwd=cwd, session = self._sessions.get(conv_id)
cc_session_id=cc_session_id, if session and session.sdk_session:
resume=not first_message, await session.sdk_session.approve(approved)
timeout=cc_timeout,
permission_mode=permission_mode,
)
log_interaction( # --- Close, list, permission ---
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
return output
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool: async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
async with self._lock: async with self._lock:
@ -184,6 +171,8 @@ class SessionManager:
return False return False
if session.owner_id and user_id and session.owner_id != user_id: if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user") raise PermissionError(f"Session {conv_id} belongs to another user")
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[conv_id] del self._sessions[conv_id]
self._save() self._save()
logger.info("Closed session %s", conv_id) logger.info("Closed session %s", conv_id)
@ -198,10 +187,8 @@ class SessionManager:
"conv_id": s.conv_id, "conv_id": s.conv_id,
"cwd": s.cwd, "cwd": s.cwd,
"owner_id": s.owner_id[-8:] if s.owner_id else None, "owner_id": s.owner_id[-8:] if s.owner_id else None,
"cc_session_id": s.cc_session_id, "busy": s.sdk_session._busy if s.sdk_session else False,
"started": s.started,
"idle_timeout": s.idle_timeout, "idle_timeout": s.idle_timeout,
"cc_timeout": s.cc_timeout,
"permission_mode": s.permission_mode, "permission_mode": s.permission_mode,
} }
for s in sessions for s in sessions
@ -217,9 +204,31 @@ class SessionManager:
if mode not in VALID_PERMISSION_MODES: if mode not in VALID_PERMISSION_MODES:
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}") raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
session.permission_mode = mode session.permission_mode = mode
if session.sdk_session:
asyncio.create_task(session.sdk_session.set_permission_mode(mode))
self._save() self._save()
logger.info("Set permission_mode=%s for session %s", mode, conv_id) 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: def _save(self) -> None:
try: try:
data = {cid: s.to_dict() for cid, s in self._sessions.items()} data = {cid: s.to_dict() for cid, s in self._sessions.items()}
@ -255,6 +264,9 @@ class SessionManager:
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout: if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
to_close.append(cid) to_close.append(cid)
for cid in to_close: for cid in to_close:
session = self._sessions[cid]
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[cid] del self._sessions[cid]
logger.info("Reaped idle session %s", cid) logger.info("Reaped idle session %s", cid)
if to_close: if to_close:

72
agent/sdk_hooks.py Normal file
View File

@ -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]),
],
}

355
agent/sdk_session.py Normal file
View File

@ -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

View File

@ -12,7 +12,7 @@ from typing import Optional, Tuple
from agent.manager import manager from agent.manager import manager
from agent.scheduler import scheduler from agent.scheduler import scheduler
from agent.task_runner import task_runner from agent.task_runner import task_runner
from agent.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.agent import agent
from orchestrator.tools import set_current_user, get_current_chat from orchestrator.tools import set_current_user, get_current_chat
@ -24,12 +24,14 @@ _PERM_ALIASES: dict[str, str] = {
"default": "default", "default": "default",
"edit": "acceptEdits", "edit": "acceptEdits",
"plan": "plan", "plan": "plan",
"auto": "dontAsk",
} }
_PERM_LABELS: dict[str, str] = { _PERM_LABELS: dict[str, str] = {
"default": "default", "default": "default",
"bypassPermissions": "bypass", "bypassPermissions": "bypass",
"acceptEdits": "edit", "acceptEdits": "edit",
"plan": "plan", "plan": "plan",
"dontAsk": "auto",
} }
@ -105,6 +107,10 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
return await _cmd_remind(args) return await _cmd_remind(args)
elif cmd == P+"perm": elif cmd == P+"perm":
return await _cmd_perm(user_id, args) 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"): elif cmd in (P+"nodes", P+"node"):
return await _cmd_nodes(user_id, args) return await _cmd_nodes(user_id, args)
else: 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: async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session.""" """Create a new session."""
if not args: if not args:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--perm MODE]\nModes: default, edit, plan, bypass" return "Usage: /new <project_dir> [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto"
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("working_dir", nargs="?", help="Project directory") parser.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message") parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds") parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass") parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
try: try:
parsed = parser.parse_args(args.split()) parsed = parser.parse_args(args.split())
except SystemExit: except SystemExit:
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N] [--perm MODE]" return "Usage: /new <project_dir> [initial_message] [--idle N] [--perm MODE]"
if not parsed.working_dir: if not parsed.working_dir:
return "Error: project_dir is required" return "Error: project_dir is required"
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
if permission_mode is None: 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 working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None initial_msg = " ".join(parsed.rest) if parsed.rest else None
@ -147,13 +152,14 @@ async def _cmd_new(user_id: str, args: str) -> str:
import uuid as _uuid import uuid as _uuid
conv_id = str(_uuid.uuid4())[:8] conv_id = str(_uuid.uuid4())[:8]
chat_id = get_current_chat()
await manager.create( await manager.create(
conv_id, conv_id,
str(resolved), str(resolved),
owner_id=user_id, owner_id=user_id,
idle_timeout=parsed.idle or 1800, idle_timeout=parsed.idle or 1800,
cc_timeout=float(parsed.timeout or 300),
permission_mode=permission_mode, permission_mode=permission_mode,
chat_id=chat_id,
) )
agent._active_conv[user_id] = conv_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: if initial_msg:
response = await manager.send(conv_id, initial_msg, user_id=user_id) response = await manager.send(conv_id, initial_msg, user_id=user_id)
chat_id = get_current_chat()
if chat_id: if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id) sessions = manager.list_sessions(user_id=user_id)
@ -174,8 +179,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
perm_label = _perm_label(permission_mode) perm_label = _perm_label(permission_mode)
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]" reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)"
if initial_msg and response: if initial_msg and response:
reply += f"\n\n{response}" reply += f"\n\n{response}"
return reply return reply
@ -289,11 +292,12 @@ async def _cmd_perm(user_id: str, args: str) -> str:
if not parts: if not parts:
return ( return (
"Usage: /perm <mode> [conv_id]\n" "Usage: /perm <mode> [conv_id]\n"
"Modes: default, edit, plan, bypass\n" "Modes: default, edit, plan, bypass, auto\n"
" default — default mode\n" " default — default mode\n"
" edit — auto-accept file edits, confirm shell commands\n" " edit — auto-accept file edits, confirm shell commands\n"
" plan — plan only, no writes\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] 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" 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: async def _cmd_nodes(user_id: str, args: str) -> str:
"""List nodes or switch active node.""" """List nodes or switch active node."""
from config import ROUTER_MODE from config import ROUTER_MODE
@ -440,11 +476,13 @@ def _cmd_help() -> str:
"""Show help.""" """Show help."""
from config import COMMAND_PREFIX as P from config import COMMAND_PREFIX as P
return f"""**Commands:** (prefix: `{P}`) return f"""**Commands:** (prefix: `{P}`)
{P}new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session {P}new <dir> [msg] [--idle N] [--perm MODE] - Create session
{P}status - Show sessions and current mode {P}status - Show sessions and current mode
{P}close [n] - Close session (active or by number) {P}close [n] - Close session (active or by number)
{P}switch <n> - Switch to session by number {P}switch <n> - Switch to session by number
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass) {P}perm <mode> [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}direct - Direct mode: messages Claude Code (no LLM overhead)
{P}smart - Smart mode: messages LLM routing (default) {P}smart - Smart mode: messages LLM routing (default)
{P}shell <cmd> - Run shell command (bypasses LLM) {P}shell <cmd> - Run shell command (bypasses LLM)
@ -462,4 +500,5 @@ def _cmd_help() -> str:
plan 只规划不执行任何写操作 plan 只规划不执行任何写操作
适合先预览 CC 的操作计划再决定是否执行 适合先预览 CC 的操作计划再决定是否执行
bypass 跳过所有权限确认CC 自动执行一切操作 bypass 跳过所有权限确认CC 自动执行一切操作
适合受信任的沙盒环境自动化任务""" 适合受信任的沙盒环境自动化任务
auto 允许所有工具不询问等效 bypass + dontAsk"""

View File

@ -128,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
lines = [] lines = []
for i, s in enumerate(sessions, 1): for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active_conv_id else " " marker = "" if s["conv_id"] == active_conv_id else " "
started = "🟢" if s["started"] else "🟡" status = "🔵" if s.get("busy") else ""
lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`") lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
sessions_md = "\n".join(lines) sessions_md = "\n".join(lines)
else: else:
sessions_md = "_No active sessions_" 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: 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. Upload a local file to Feishu and send it as a file message.

View File

@ -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.") await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
return 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 from config import ROUTER_MODE
if ROUTER_MODE: if ROUTER_MODE:
from router.nodes import get_node_registry 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]) 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: def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks.""" """Construct the EventDispatcherHandler with all registered callbacks."""
handler = ( handler = (
lark.EventDispatcherHandler.builder("", "") lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message) .register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any) .register_p1_customized_event("im.message.receive_v1", _handle_any)
.register_p1_customized_event("card.action.trigger", _handle_card_action)
.build() .build()
) )
return handler return handler

View File

@ -21,6 +21,10 @@ OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7") OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve() WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "") 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_MODE: bool = _cfg.get("ROUTER_MODE", False)
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "") ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")

View File

@ -1,16 +1,18 @@
""" """
Root conftest runs before pytest collects any test files or imports any Root conftest runs before pytest collects any test files or imports any
production modules. Patches config._CONFIG_PATH to point at the test keyring production modules. Creates a temporary keyring.yaml from the test keyring
so that `import config` never tries to open the real keyring.yaml. so that `import config` works without the real keyring.yaml.
Must live at the repo root (not inside tests/) to fire before collection. Must live at the repo root (not inside tests/) to fire before collection.
""" """
import shutil
from pathlib import Path 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 # If the real keyring.yaml doesn't exist, copy the test version so config.py
import config as _config_mod # can load at module import time. This file is gitignored.
_config_mod._CONFIG_PATH = _TEST_KEYRING if not _KEYRING.exists() and _TEST_KEYRING.exists():
importlib.reload(_config_mod) shutil.copy2(_TEST_KEYRING, _KEYRING)

View File

@ -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. 选择回调订阅方式 | 回调订阅方式分为 **使用长连接接收回调****将回调发送至开发者服务器** 两种,你可以根据需要自行选择任一订阅方式。<br>**注意事项**- **使用长连接接收回调** 方式是飞书 SDK 内提供的能力,你可以通过集成飞书 SDK 与开放平台建立一条 WebSocket 全双工通道(你的服务器需要能够访问公网)。后续当应用订阅的回调发生时,开放平台会通过该通道向你的服务器发送消息。详细配置说明参见[使用长连接接收回调](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address)。<br>- **将回调发送至开发者服务器** 方式是传统的 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. 接收回调 | 根据不同的回调订阅方式接收回调:<br>- **使用长连接接收回调** 方式已经封装了鉴权逻辑,无需进行数据解密与验签操作,直接接收来自开放平台的回调请求即可。<br>- **将回调发送至开发者服务器** 方式需要你根据应用的加密策略进行安全校验,如果是加密回调,需要先解密回调,再解析回调详情。具体操作参见[接收回调](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) | 用户点击卡片上配置回传交互的组件时,触发此回调。<br>可通过返回 toast、更新后的卡片内容等反馈用户的交互。
链接预览 | [拉取链接预览数据](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/development-link-preview/pull-link-preview-data-callback-structure) | 用户在聊天中查看匹配应用注册的URL规则的链接时触发此回调。<br>可通过返回文字链、卡片等链接预览内容,为裸链扩展链接预览效果。
卡片 | [消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure) | 当用户点击卡片上添加了回传交互的组件时,开发者注册的服务端回调地址将收到此回调。<br>开发者可声明通过弹出 toast、更新卡片、保持原内容不变等方式来响应用户交互。<br><md-alert type="tip" icon="none">该回调使用旧版的协议,兼容历史的机器人[回调配置](https://open.feishu.cn/document/ukTMukTMukTM/uYzMxEjL2MTMx4iNzETM)。
q

View File

@ -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)。
## 回调
基本信息 | &nbsp;
---|---
回调类型 | card.action.trigger
支持的应用类型 | Custom App、Store App
权限要求<br>**订阅该事件所需的权限,开启其中任意一项权限即可订阅**<br>开启任一权限即可 | 暂无
字段权限要求 | **注意事项**:事件结构体中存在 `user_id` 敏感字段,仅当应用开启“获取用户 user ID”权限后才会返回。<br>获取用户 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 | 表单容器内用户提交的数据。示例值:<br>```JSON<br>{<br>"field name 1": [ // 表单容器内某多选组件的 name 和 value<br>"selectDemo1",<br>"selectDemo2"<br>], <br>"field name 2": "value 2", // 表单容器内某交互组件的 name 和 value<br>"field name 3": "value 3", // 表单容器内某交互组件的 name 和 value<br>}<br>```
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。<br>不同的值的展示效果如下图所示:<br>![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 | 否 | 多语言提示文案。示例配置:<br>```json<br>{<br>"i18n": {<br>"zh_cn": "更新成功!",<br>"en_us": "Successful update"<br>}<br>}<br>```
key | string | 否 | 语言。可选值:<br>- `zh_cn`: 简体中文<br>- `en_us`: 英文<br>- `zh_hk`: 繁体中文(香港)<br>- `zh_tw`: 繁体中文(台湾)<br>- `ja_jp`: 日语<br>- `id_id`: 印尼语<br>- `vi_vn`: 越南语<br>- `th_th`: 泰语<br>- `pt_br`: 葡萄牙语<br>- `es_es`: 西班牙语<br>- `ko_kr`: 韩语<br>- `de_de`: 德语<br>- `fr_fr`: 法语<br>- `it_it`: 意大利语<br>- `ru_ru`: 俄语<br>- `ms_my`: 马来语
value | string | 否 | 语言对应的文案。
card | object | 否 | 卡片数据。
type | string | 是 | 卡片类型。可选值:<br>- `template`:搭建工具构建的卡片,可视为一个卡片模板<br>- `raw`:由 JSON 构建的卡片<br>要使用卡片 JSON 代码响应,请选择 `raw`
data | object | 是 | 卡片的 JSON 数据。 <br>- 若发送卡片时,卡片 JSON 结构为 1.0 版本,那么你需传入卡片 JSON 1.0 数据。详情参考[卡片 JSON 1.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-structure)<br>- 若发送卡片时,卡片 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。<br>不同的值的展示效果如下图所示:<br>![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 | 否 | 多语言提示文案。示例配置:<br>```json<br>{<br>"i18n": {<br>"zh_cn": "更新成功!",<br>"en_us": "Successful update"<br>}<br>}<br>```
key | string | 否 | 语言。可选值:<br>- `zh_cn`: 简体中文<br>- `en_us`: 英文<br>- `zh_hk`: 繁体中文(香港)<br>- `zh_tw`: 繁体中文(台湾)<br>- `ja_jp`: 日语<br>- `id_id`: 印尼语<br>- `vi_vn`: 越南语<br>- `th_th`: 泰语<br>- `pt_br`: 葡萄牙语<br>- `es_es`: 西班牙语<br>- `ko_kr`: 韩语<br>- `de_de`: 德语<br>- `fr_fr`: 法语<br>- `it_it`: 意大利语<br>- `ru_ru`: 俄语<br>- `ms_my`: 马来语
value | string | 否 | 语言对应的文案。
card | object | 否 | 卡片数据。
type | string | 是 | 卡片类型。可选值:<br>- `template`:搭建工具构建的卡片,可视为一个卡片模板<br>- `raw`:由 JSON 构建的卡片<br>要使用卡片模板响应,请选择 `template`
data | object | 是 | 卡片模板的数据。
template_id | string | 是 | 搭建工具中创建的卡片(也称卡片模板)的 ID如 AAqigYkzabcef。可在搭建工具中通过复制卡片模板 ID 获取。<br>![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”为示例<br>```json<br>{<br>"open_id": "ou_d506829e8b6a17607e56bcd6b1aabcef"<br>}<br>```
template_version_name | string | 否 | 搭建工具中创建的卡片的版本号,如 1.0.0。卡片发布后,将生成版本号。可在搭建工具 **版本管理** 处获取。<br>![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 | 应用未配置飞书卡片回调地址或配置的请求地址无效。<br>若应用已配置,请确保你已创建并发布了最新的应用版本使修改生效。 | 1. 前往[开发者后台](https://open.feishu.cn/app),点击目标应用,选择 **开发配置** > **事件与回调**<br>2. 在 **事件与回调** 页面 **回调配置** 页签下,填写正确有效的请求地址并保存。<br>3. 在 **已订阅的回调** 项中,确保已添加卡片回传交互回调。<br>**提示**:你也可以选择使用长连接接收回调。了解更多,参考[配置回调订阅方式](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` 是表单容器内组件的唯一标识,用于识别用户提交的数据属于哪个组件,在单张卡片内不可为空、不可重复。<br>- 如果你使用卡片 JSON 搭建卡片,请确保所有的 name 属性的值不为空。`name` 数据类型为字符串。<br>- 如果你使用卡片搭建工具搭建卡片:<br>1. 在卡片编辑页面,选中表单内的交互组件,在右侧属性页签下,确保 **表单项标识** 已填写。<br>![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/93894aaf05f60f3576e64cb5a0f22569_62E0goGKeA.png?height=482&lazyload=true&width=1547)<br>2. 点击右上角的 **保存**,然后点击 **发布**,确保修改生效。<br>![](//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)检查响应回调的结构体的格式是否有误。<br>- 如果你添加的是旧版卡片回传交互(`card.action.trigger_v1`)回调,请参考[消息卡片回传交互(旧)](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/configuring-card-callbacks/card-callback-structure)检查响应回调的结构体的格式是否有误。<br>- 如果你同时添加了新版和旧版卡片回传交互回调,响应其中任一回调即为成功响应。建议你删除多余的请求方式。
200673 | 请求的卡片回调服务返回了错误的卡片。 | - 如果你添加的是新版卡片回传交互(`card.action.trigger`)回调,请参考[卡片回传交互](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-callback-communication#65787609)检查响应回调的结构体中 `card` 部分是否有误。<br>- 如果你添加的是旧版卡片回传交互(`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)。

View File

@ -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. 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`. 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: Guidelines:
- Relay Claude Code's output verbatim. - Relay Claude Code's output verbatim.
- If no active session and the user sends a task without naming a directory, ask which project. - 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 # Passthrough mode: if enabled and active session, bypass LLM
if self._passthrough[user_id] and active_conv: if self._passthrough[user_id] and active_conv:
try: 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]) logger.info("<<< [passthrough] reply: %r", reply[:120])
return reply return reply
except KeyError: except KeyError:

View File

@ -83,7 +83,6 @@ class CreateConversationInput(BaseModel):
) )
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning") 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)") 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): class SendToConversationInput(BaseModel):
@ -108,23 +107,24 @@ class CreateConversationTool(BaseTool):
) )
args_schema: Type[BaseModel] = CreateConversationInput 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") 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: try:
resolved = _resolve_dir(working_dir) resolved = _resolve_dir(working_dir)
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(exc)}) return json.dumps({"error": str(exc)})
user_id = get_current_user() user_id = get_current_user()
chat_id = get_current_chat()
conv_id = str(uuid.uuid4())[:8] conv_id = str(uuid.uuid4())[:8]
await manager.create( await manager.create(
conv_id, conv_id,
str(resolved), str(resolved),
owner_id=user_id or "", owner_id=user_id or "",
idle_timeout=idle_timeout or 1800, idle_timeout=idle_timeout or 1800,
cc_timeout=cc_timeout or 300.0, chat_id=chat_id,
) )
result: dict = { result: dict = {
@ -154,8 +154,9 @@ class SendToConversationTool(BaseTool):
async def _arun(self, conv_id: str, message: str) -> str: async def _arun(self, conv_id: str, message: str) -> str:
user_id = get_current_user() user_id = get_current_user()
chat_id = get_current_chat()
try: 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) return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
except KeyError: except KeyError:
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"}) return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
@ -752,12 +753,71 @@ class RunCommandTool(BaseTool):
return result 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 # Module-level tool list for easy import
TOOLS = [ TOOLS = [
CreateConversationTool(), CreateConversationTool(),
SendToConversationTool(), SendToConversationTool(),
ListConversationsTool(), ListConversationsTool(),
CloseConversationTool(), CloseConversationTool(),
SessionProgressTool(),
InterruptConversationTool(),
RunCommandTool(), RunCommandTool(),
ShellTool(), ShellTool(),
FileReadTool(), FileReadTool(),

View File

@ -1,61 +1,43 @@
""" """
Master test fixtures for PhoneWork BDD tests. Shared test fixtures for PhoneWork tests.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from pathlib import Path from pathlib import Path
from typing import Any from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
TESTS_DIR = Path(__file__).parent
CASSETTES_DIR = TESTS_DIR / "cassettes"
CASSETTES_DIR.mkdir(exist_ok=True)
# ── Feishu send mock ───────────────────────────────────────────────────────── # ── Feishu send mock ─────────────────────────────────────────────────────────
@pytest.fixture @pytest.fixture
def feishu_calls(): def feishu_calls():
""" """Capture all calls to bot.feishu send functions."""
Capture all calls to bot.feishu send functions. captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []}
Lazy imports inside commands.py pull from bot.feishu at call time,
so patching the module attributes is sufficient.
"""
captured: dict[str, list] = {"texts": [], "cards": [], "files": []}
async def mock_send_text(receive_id, receive_id_type, text): 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): 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"): 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), \ 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_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 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 ─────────────────────────────────────────────────── # ── Singleton state resets ───────────────────────────────────────────────────
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -110,13 +92,6 @@ def reset_contextvars():
set_current_chat(None) set_current_chat(None)
@pytest.fixture(autouse=True)
def reset_reply(pytestconfig):
"""Clear _reply before each test so stale values don't leak between scenarios."""
pytestconfig._reply = None
yield
# ── Working directory isolation ────────────────────────────────────────────── # ── Working directory isolation ──────────────────────────────────────────────
@pytest.fixture @pytest.fixture
@ -127,41 +102,3 @@ def tmp_working_dir(tmp_path, monkeypatch):
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path) monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
(tmp_path / "myproject").mkdir() (tmp_path / "myproject").mkdir()
return tmp_path 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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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 ""

View File

@ -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"

View File

@ -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)}"

View File

@ -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

384
tests/test_commands.py Normal file
View File

@ -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

816
tests/test_sdk_migration.py Normal file
View File

@ -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