Compare commits

..
4 Commits
Author SHA1 Message Date
Yuyao Huang 72ebf3b75d feat(question): implement AskUserQuestion tool support
- Add question card builder and answer handling in feishu.py
- Extend SDKSession with pending question state and answer method
- Update card callback handler to support question answers
- Add test cases for question flow and card responses
- Document usage with test_can_use_tool_ask.py example
2026-04-02 08:52:50 +08:00
Yuyao Huang 26746335c4 refactor(commands): rename status command to list and update related references
feat(feishu): update card schema to 2.0 and simplify approval card structure

docs(feishu): add documentation for card json schema 2.0 changes
2026-04-01 14:50:30 +08:00
Yuyao Huang 44bd1a4300 feat(handler): implement card callback response with toast and updated card
Add visual feedback for card actions by returning toast messages and updated cards per Feishu requirements. The handler now responds within 3 seconds with appropriate success/warning toasts and card updates showing approval/denial status. Also added operator tracking in logs and comprehensive tests.
2026-04-01 13:22:41 +08:00
Yuyao Huang eac90941ef 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
2026-04-01 12:51:00 +08:00
37 changed files with 3470 additions and 983 deletions
+3 -1
View File
@@ -7,6 +7,7 @@
| Project architecture, deployment, bot commands | `README.md` |
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` |
| SDK session implementation (secretary model) | `agent/sdk_session.py` |
| Feishu card / markdown formatting | `docs/feishu/` |
## Claude Agent SDK
@@ -17,6 +18,7 @@ When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
- `test_query_read_edit.py``query()` one-shot: Read + Edit with `allowed_tools`
- `test_client_write_resume.py``ClaudeSDKClient`: Write + session resume via `resume=session_id`
- `test_hooks_audit_deny.py` — Hooks: `PostToolUse` audit + `PreToolUse` deny
- `test_can_use_tool_ask.py``can_use_tool`: intercept `AskUserQuestion`, pre-fill answers via `updated_input`
### Key rules (verified by testing)
@@ -30,7 +32,7 @@ When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
```
bot/ Feishu event handling, commands, message sending
orchestrator/ LangChain agent + tools (session management, shell, files, web)
agent/ Claude Code runner, session manager, task runner, scheduler, audit
agent/ SDK session (secretary model), session manager, hooks, audit
router/ Multi-host routing (public VPS side)
host_client/ Host client (behind NAT, connects to router)
shared/ Wire protocol for router ↔ host communication
+55
View File
@@ -58,6 +58,61 @@ def log_interaction(
logger.exception("Failed to log audit entry for session %s", conv_id)
def log_tool_use(
session_id: str,
tool_name: str,
tool_input: dict,
tool_response: Optional[object] = None,
) -> None:
"""Log a tool call to the audit JSONL file."""
try:
_ensure_audit_dir()
log_file = AUDIT_DIR / f"{session_id}.jsonl"
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"type": "tool_use",
"session_id": session_id,
"tool_name": tool_name,
"tool_input": str(tool_input)[:500],
}
if tool_response is not None:
entry["tool_response"] = str(tool_response)[:500]
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
logger.exception("Failed to log tool use for session %s", session_id)
def log_permission_decision(
conv_id: str,
tool_name: str,
tool_input: dict,
approved: bool,
) -> None:
"""Log a permission approval/denial decision."""
try:
_ensure_audit_dir()
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"type": "permission_decision",
"conv_id": conv_id,
"tool_name": tool_name,
"tool_input": str(tool_input)[:300],
"approved": approved,
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
logger.exception("Failed to log permission decision for session %s", conv_id)
def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
"""Read the audit log for a session."""
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
+109 -91
View File
@@ -5,18 +5,20 @@ from __future__ import annotations
import asyncio
import json
import logging
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, List, Optional
from typing import Optional
from agent.cc_runner import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES
from agent.audit import log_interaction
from agent.sdk_session import (
SDKSession,
SessionProgress,
DEFAULT_PERMISSION_MODE,
VALID_PERMISSION_MODES,
)
logger = logging.getLogger(__name__)
DEFAULT_IDLE_TIMEOUT = 30 * 60
DEFAULT_CC_TIMEOUT = 300.0
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
@@ -25,21 +27,27 @@ class Session:
conv_id: str
cwd: str
owner_id: str = ""
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
last_activity: float = 0.0
started: bool = False
idle_timeout: int = DEFAULT_IDLE_TIMEOUT
cc_timeout: float = DEFAULT_CC_TIMEOUT
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE)
chat_id: str | None = None
# Runtime only — not serialized
sdk_session: SDKSession | None = field(default=None, repr=False)
def touch(self) -> None:
self.last_activity = asyncio.get_event_loop().time()
def to_dict(self) -> dict:
return asdict(self)
d = asdict(self)
d.pop("sdk_session", None)
return d
@classmethod
def from_dict(cls, data: dict) -> "Session":
data.pop("sdk_session", None)
# Migration: remove old cc_runner fields if present in persisted data
for old_key in ("cc_session_id", "started", "cc_timeout"):
data.pop(old_key, None)
return cls(**data)
@@ -60,6 +68,9 @@ class SessionManager:
if self._reaper_task:
self._reaper_task.cancel()
async with self._lock:
for session in self._sessions.values():
if session.sdk_session:
await session.sdk_session.close()
self._sessions.clear()
if PERSISTENCE_FILE.exists():
PERSISTENCE_FILE.unlink()
@@ -70,8 +81,8 @@ class SessionManager:
working_dir: str,
owner_id: str = "",
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
cc_timeout: float = DEFAULT_CC_TIMEOUT,
permission_mode: str = DEFAULT_PERMISSION_MODE,
chat_id: str | None = None,
) -> Session:
async with self._lock:
session = Session(
@@ -79,103 +90,85 @@ class SessionManager:
cwd=working_dir,
owner_id=owner_id,
idle_timeout=idle_timeout,
cc_timeout=cc_timeout,
permission_mode=permission_mode,
chat_id=chat_id,
)
self._sessions[conv_id] = session
self._save()
logger.info(
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)",
"Created session %s (owner=...%s) in %s (idle=%ds, perm=%s)",
conv_id, owner_id[-8:] if owner_id else "-", working_dir,
idle_timeout, cc_timeout, permission_mode,
idle_timeout, permission_mode,
)
return session
async def send(self, conv_id: str, message: str, user_id: Optional[str] = None, direct: bool = False) -> str:
async with self._lock:
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
# --- Secretary model: async send (returns immediately) ---
async def send_message(
self, conv_id: str, message: str,
user_id: Optional[str] = None, chat_id: Optional[str] = None,
) -> str:
"""Send a message to the session. Returns immediately; result pushed to Feishu on completion."""
session = self._get_session(conv_id, user_id)
session.touch()
cwd = session.cwd
cc_session_id = session.cc_session_id
cc_timeout = session.cc_timeout
permission_mode = session.permission_mode
first_message = not session.started
if first_message:
session.started = True
self._save()
self._ensure_sdk_session(session, chat_id)
return await session.sdk_session.send(message, chat_id)
if not direct and cc_timeout > 60:
from agent.task_runner import task_runner
from orchestrator.tools import get_current_chat, set_current_chat, set_current_user
async def send_and_wait(
self, conv_id: str, message: str,
user_id: Optional[str] = None, chat_id: Optional[str] = None,
) -> str:
"""Send and wait for completion. For LLM agent tool calls that need the result."""
session = self._get_session(conv_id, user_id)
session.touch()
self._ensure_sdk_session(session, chat_id)
return await session.sdk_session.send_and_wait(message, chat_id)
# --- 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()
return await self.send_message(conv_id, message, user_id=user_id, chat_id=chat_id)
async def run_task():
output = await run_claude(
message,
cwd=cwd,
cc_session_id=cc_session_id,
resume=not first_message,
timeout=cc_timeout,
permission_mode=permission_mode,
)
log_interaction(
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
return output
# --- Progress, interrupt, approve ---
async def on_task_complete(task) -> None:
if not chat_id or not user_id or not task.result:
return
set_current_user(user_id)
set_current_chat(chat_id)
from orchestrator.agent import agent
follow_up = (
f"CC task completed. Output:\n{task.result}\n\n"
f"Original request was: {message}\n\n"
"If the user asked you to send a file, use send_file now. "
"Otherwise just acknowledge completion."
)
reply = await agent.run(user_id, follow_up)
if reply:
from bot.feishu import send_text
await send_text(chat_id, "chat_id", reply)
def get_progress(self, conv_id: str, user_id: Optional[str] = None) -> SessionProgress | None:
"""Query session progress. Primary interface for the secretary AI."""
session = self._sessions.get(conv_id)
if not session:
return None
if session.owner_id and user_id and session.owner_id != user_id:
return None
if session.sdk_session:
return session.sdk_session.get_progress()
return SessionProgress()
task_id = await task_runner.submit(
run_task(),
description=f"CC session {conv_id}: {message[:50]}",
notify_chat_id=chat_id,
user_id=user_id,
on_complete=on_task_complete,
)
return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done."
async def interrupt(self, conv_id: str, user_id: Optional[str] = None) -> bool:
"""Interrupt the currently running task in a session."""
session = self._get_session(conv_id, user_id)
if session.sdk_session:
await session.sdk_session.interrupt()
return True
return False
output = await run_claude(
message,
cwd=cwd,
cc_session_id=cc_session_id,
resume=not first_message,
timeout=cc_timeout,
permission_mode=permission_mode,
)
async def approve(self, conv_id: str, approved: bool) -> None:
"""Resolve a pending tool approval for a session."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.approve(approved)
log_interaction(
conv_id=conv_id,
prompt=message,
response=output,
cwd=cwd,
user_id=user_id,
)
async def answer_question(self, conv_id: str, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's answers."""
session = self._sessions.get(conv_id)
if session and session.sdk_session:
await session.sdk_session.answer_question(answers)
return output
# --- Close, list, permission ---
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
async with self._lock:
@@ -184,6 +177,8 @@ class SessionManager:
return False
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[conv_id]
self._save()
logger.info("Closed session %s", conv_id)
@@ -198,10 +193,8 @@ class SessionManager:
"conv_id": s.conv_id,
"cwd": s.cwd,
"owner_id": s.owner_id[-8:] if s.owner_id else None,
"cc_session_id": s.cc_session_id,
"started": s.started,
"busy": s.sdk_session._busy if s.sdk_session else False,
"idle_timeout": s.idle_timeout,
"cc_timeout": s.cc_timeout,
"permission_mode": s.permission_mode,
}
for s in sessions
@@ -217,9 +210,31 @@ class SessionManager:
if mode not in VALID_PERMISSION_MODES:
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
session.permission_mode = mode
if session.sdk_session:
asyncio.create_task(session.sdk_session.set_permission_mode(mode))
self._save()
logger.info("Set permission_mode=%s for session %s", mode, conv_id)
# --- Internal ---
def _get_session(self, conv_id: str, user_id: Optional[str] = None) -> Session:
session = self._sessions.get(conv_id)
if session is None:
raise KeyError(f"No session for conv_id={conv_id!r}")
if session.owner_id and user_id and session.owner_id != user_id:
raise PermissionError(f"Session {conv_id} belongs to another user")
return session
def _ensure_sdk_session(self, session: Session, chat_id: str | None = None) -> None:
if session.sdk_session is None:
session.sdk_session = SDKSession(
conv_id=session.conv_id,
cwd=session.cwd,
owner_id=session.owner_id,
permission_mode=session.permission_mode,
chat_id=chat_id or session.chat_id,
)
def _save(self) -> None:
try:
data = {cid: s.to_dict() for cid, s in self._sessions.items()}
@@ -255,6 +270,9 @@ class SessionManager:
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
to_close.append(cid)
for cid in to_close:
session = self._sessions[cid]
if session.sdk_session:
await session.sdk_session.close()
del self._sessions[cid]
logger.info("Reaped idle session %s", cid)
if to_close:
+72
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]),
],
}
+414
View File
@@ -0,0 +1,414 @@
"""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
pending_question: dict | None = None # non-None → waiting for user answer to AskUserQuestion
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 = ""
# AskUserQuestion mechanism
self._pending_question: asyncio.Future | None = None
self._pending_question_data: dict | None = None # {questions: [...], conv_id: ...}
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,
pending_question=self._pending_question_data,
)
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 answer_question(self, answers: dict[str, str]) -> None:
"""Resolve a pending AskUserQuestion with user's selected answers.
Args:
answers: maps question text → selected option label.
"""
if self._pending_question and not self._pending_question.done():
self._pending_question.set_result(answers)
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 — route to question card or approval card based on tool type."""
# Auto-allow read-only tools
if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"):
return PermissionResultAllow()
# AskUserQuestion: show options to user, collect answer, return via updated_input
if tool_name == "AskUserQuestion":
return await self._handle_ask_user_question(input_data)
if not self.chat_id:
return PermissionResultAllow()
# Regular tools: approval flow
return await self._handle_tool_approval(tool_name, input_data)
async def _handle_ask_user_question(self, input_data: dict) -> PermissionResult:
"""Handle AskUserQuestion: send question card, wait for answer, return updated_input."""
if not self.chat_id:
return PermissionResultAllow()
questions = input_data.get("questions", [])
if not questions:
return PermissionResultAllow()
from bot.feishu import send_card, build_question_card
# Build and send question card
self._pending_question_data = {"questions": questions, "conv_id": self.conv_id}
card = build_question_card(
conv_id=self.conv_id,
questions=questions,
)
await send_card(self.chat_id, "chat_id", card)
# Wait for user's answer (via card callback or text reply)
loop = asyncio.get_running_loop()
self._pending_question = loop.create_future()
try:
answers = await asyncio.wait_for(
self._pending_question, timeout=APPROVAL_TIMEOUT
)
except asyncio.TimeoutError:
answers = {}
from bot.feishu import send_markdown
await send_markdown(self.chat_id, "chat_id", "⏰ 问题超时,已跳过。")
finally:
self._pending_question_data = None
# Pre-fill answers in the tool input
modified_input = dict(input_data)
if "answers" not in modified_input or not isinstance(modified_input.get("answers"), dict):
modified_input["answers"] = {}
modified_input["answers"].update(answers)
return PermissionResultAllow(updated_input=modified_input)
async def _handle_tool_approval(self, tool_name: str, input_data: dict) -> PermissionResult:
"""Handle regular tool approval: send approval card, wait for approve/deny."""
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)
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
+64 -29
View File
@@ -12,7 +12,7 @@ from typing import Optional, Tuple
from agent.manager import manager
from agent.scheduler import scheduler
from agent.task_runner import task_runner
from agent.cc_runner import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, get_current_chat
@@ -24,12 +24,14 @@ _PERM_ALIASES: dict[str, str] = {
"default": "default",
"edit": "acceptEdits",
"plan": "plan",
"auto": "dontAsk",
}
_PERM_LABELS: dict[str, str] = {
"default": "default",
"bypassPermissions": "bypass",
"acceptEdits": "edit",
"plan": "plan",
"dontAsk": "auto",
}
@@ -83,7 +85,7 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
if cmd in (P+"new", P+"n"):
return await _cmd_new(user_id, args)
elif cmd in (P+"status", P+"list", P+"ls", P+"l"):
elif cmd in (P+"list", P+"ls", P+"l", P+"status"):
return await _cmd_status(user_id)
elif cmd in (P+"close", P+"c"):
return await _cmd_close(user_id, args)
@@ -105,6 +107,10 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
return await _cmd_remind(args)
elif cmd == P+"perm":
return await _cmd_perm(user_id, args)
elif cmd in (P+"stop", P+"interrupt"):
return await _cmd_stop(user_id)
elif cmd in (P+"progress", P+"prog", P+"p"):
return await _cmd_progress(user_id)
elif cmd in (P+"nodes", P+"node"):
return await _cmd_nodes(user_id, args)
else:
@@ -114,26 +120,25 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
async def _cmd_new(user_id: str, args: str) -> str:
"""Create a new session."""
if not args:
return "Usage: /new <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.add_argument("working_dir", nargs="?", help="Project directory")
parser.add_argument("rest", nargs="*", help="Initial message")
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass")
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
try:
parsed = parser.parse_args(args.split())
except SystemExit:
return "Usage: /new <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:
return "Error: project_dir is required"
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
if permission_mode is None:
return f"Invalid --perm. Valid modes: default, edit, plan, bypass"
return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto"
working_dir = parsed.working_dir
initial_msg = " ".join(parsed.rest) if parsed.rest else None
@@ -147,13 +152,14 @@ async def _cmd_new(user_id: str, args: str) -> str:
import uuid as _uuid
conv_id = str(_uuid.uuid4())[:8]
chat_id = get_current_chat()
await manager.create(
conv_id,
str(resolved),
owner_id=user_id,
idle_timeout=parsed.idle or 1800,
cc_timeout=float(parsed.timeout or 300),
permission_mode=permission_mode,
chat_id=chat_id,
)
agent._active_conv[user_id] = conv_id
@@ -161,7 +167,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
if initial_msg:
response = await manager.send(conv_id, initial_msg, user_id=user_id)
chat_id = get_current_chat()
if chat_id:
from bot.feishu import send_card, send_text, build_sessions_card
sessions = manager.list_sessions(user_id=user_id)
@@ -174,8 +179,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
perm_label = _perm_label(permission_mode)
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
if parsed.timeout:
reply += f" (timeout: {parsed.timeout}s)"
if initial_msg and response:
reply += f"\n\n{response}"
return reply
@@ -289,11 +292,12 @@ async def _cmd_perm(user_id: str, args: str) -> str:
if not parts:
return (
"Usage: /perm <mode> [conv_id]\n"
"Modes: default, edit, plan, bypass\n"
"Modes: default, edit, plan, bypass, auto\n"
" default — default mode\n"
" edit — auto-accept file edits, confirm shell commands\n"
" plan — plan only, no writes\n"
" bypass — skip all permission checks"
" bypass — skip all permission checks\n"
" auto — allow all tools, don't ask"
)
alias = parts[0]
@@ -403,11 +407,43 @@ async def _cmd_remind(args: str) -> str:
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
async def _cmd_stop(user_id: str) -> str:
"""Interrupt the current task in the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
try:
success = await manager.interrupt(conv_id, user_id)
return "✓ Interrupted" if success else "No active task."
except Exception as e:
return f"Error: {e}"
async def _cmd_progress(user_id: str) -> str:
"""Show progress of the active session."""
conv_id = agent.get_active_conv(user_id)
if not conv_id:
return "No active session."
progress = manager.get_progress(conv_id, user_id)
if not progress:
return "Session not found."
if not progress.busy:
if progress.last_result:
return f"✅ 已完成\n\n{progress.last_result[:500]}"
return "空闲中,无正在执行的任务。"
elapsed = int(progress.elapsed_seconds)
tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none"
pending = ""
if progress.pending_approval:
pending = f"\n⚠️ 等待审批: {progress.pending_approval}"
return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}"
async def _cmd_nodes(user_id: str, args: str) -> str:
"""List nodes or switch active node."""
from config import ROUTER_MODE
if not ROUTER_MODE:
return "Not in router mode. Run standalone.py for multi-host support."
return "Not in router mode."
from router.nodes import get_node_registry
registry = get_node_registry()
@@ -440,26 +476,25 @@ def _cmd_help() -> str:
"""Show help."""
from config import COMMAND_PREFIX as P
return f"""**Commands:** (prefix: `{P}`)
{P}new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
{P}status - Show sessions and current mode
{P}close [n] - Close session (active or by number)
{P}switch <n> - Switch to session by number
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass)
{P}direct - Direct mode: messages → Claude Code (no LLM overhead)
{P}new <dir> [msg] [--idle N] [--perm MODE] - Create session (alias: {P}n)
{P}list - Show sessions and current mode (alias: {P}ls, {P}l, {P}status)
{P}close [n] - Close session (active or by number) (alias: {P}c)
{P}switch <n> - Switch to session by number (alias: {P}s)
{P}perm <mode> [conv_id] - Set permission mode (alias: {P}perm)
{P}stop - Interrupt the current task (alias: {P}interrupt)
{P}progress - Show task progress (alias: {P}p)
{P}direct - Direct mode: messages → Claude Code
{P}smart - Smart mode: messages → LLM routing (default)
{P}shell <cmd> - Run shell command (bypasses LLM)
{P}shell <cmd> - Run shell command
{P}remind <time> <msg> - Set reminder (e.g. {P}remind 10m check build)
{P}tasks - List background tasks
{P}nodes - List connected host nodes
{P}node <name> - Switch active node
{P}retry - Retry last message
{P}help - Show this help
{P}help - Show this help (alias: {P}h, {P}?)
**Permission modes** (used by {P}perm and {P}new --perm):
default — 默认模式,遇到文件编辑操作时手动确认
edit — 自动接受文件编辑,shell 命令仍需手动确认
适合:日常开发,需要对命令执行保持控制
default — 默认模式,需审批工具调用
edit — 自动接受文件编辑,shell 命令仍需确认
plan — 只规划、不执行任何写操作
适合:先预览 CC 的操作计划再决定是否执行
bypass — 跳过所有权限确认,CC 自动执行一切操作
适合:受信任的沙盒环境、自动化任务"""
bypass — 跳过所有权限确认
auto — 允许所有工具,不询问"""
+102 -2
View File
@@ -128,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
lines = []
for i, s in enumerate(sessions, 1):
marker = "" if s["conv_id"] == active_conv_id else " "
started = "🟢" if s["started"] else "🟡"
lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`")
status = "🔵" if s.get("busy") else ""
lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
sessions_md = "\n".join(lines)
else:
sessions_md = "_No active sessions_"
@@ -148,6 +148,106 @@ 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).
Note: JSON 2.0 does NOT support "action" wrapper or "note" components.
Buttons go directly in elements; use div with small text for the note.
"""
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": "orange",
},
"body": {
"elements": [
{
"tag": "markdown",
"content": f"**工具:** `{tool_name}`\n**参数:** {summary}",
},
{
"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": "div",
"text": {
"tag": "plain_text",
"content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n",
},
},
],
},
}
def build_question_card(conv_id: str, questions: list[dict]) -> dict:
"""Build a question card for AskUserQuestion (schema 2.0).
Each question's options become buttons. The first question is shown
prominently; multi-question support shows them sequentially.
"""
elements: list[dict] = []
for i, q in enumerate(questions):
question_text = q.get("question", "")
header = q.get("header", "")
options = q.get("options", [])
if header:
elements.append({
"tag": "markdown",
"content": f"**{header}**\n{question_text}",
})
else:
elements.append({
"tag": "markdown",
"content": f"**{question_text}**",
})
for opt in options:
label = opt.get("label", "")
desc = opt.get("description", "")
button_text = f"{label}{desc}" if desc else label
# Truncate long button text
if len(button_text) > 60:
button_text = button_text[:57] + "..."
elements.append({
"tag": "button",
"text": {"tag": "plain_text", "content": button_text},
"type": "default",
"value": {
"action": "answer_question",
"conv_id": conv_id,
"question": question_text,
"answer": label,
},
})
elements.append({
"tag": "div",
"text": {"tag": "plain_text", "content": "也可直接输入文字回复"},
})
return {
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "blue",
},
"body": {"elements": elements},
}
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.
+157
View File
@@ -129,6 +129,46 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
return
# Text approval fallback: user replies y/n to a pending tool approval
if text.strip().lower() in ("y", "n", "yes", "no"):
approved = text.strip().lower() in ("y", "yes")
from orchestrator.agent import agent as _agent
from agent.manager import manager as _manager
conv_id = _agent.get_active_conv(user_id)
if conv_id:
session = _manager._sessions.get(conv_id)
if (
session
and session.sdk_session
and session.sdk_session._pending_approval
and not session.sdk_session._pending_approval.done()
):
await _manager.approve(conv_id, approved)
label = "✅ 已批准" if approved else "❌ 已拒绝"
await send_text(chat_id, "chat_id", label)
return
# Text answer fallback: any text reply when a question is pending
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_question
and not session.sdk_session._pending_question.done()
and session.sdk_session._pending_question_data
):
# Use text as answer to the first pending question
questions = session.sdk_session._pending_question_data.get("questions", [])
if questions:
q_text = questions[0].get("question", "")
await _manager.answer_question(conv_id, {q_text: text.strip()})
await send_text(chat_id, "chat_id", f"✅ 已回答: {text.strip()}")
return
from config import ROUTER_MODE
if ROUTER_MODE:
from router.nodes import get_node_registry
@@ -196,12 +236,129 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
def _handle_card_action(data: "P2CardActionTrigger") -> "P2CardActionTriggerResponse":
"""Handle Feishu card button clicks via register_p2_card_action_trigger.
Per docs/feishu/card_callback_communication.md:
- Must respond within 3 seconds
- Return P2CardActionTriggerResponse with toast + updated card
"""
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackCard, CallBackToast, P2CardActionTriggerResponse,
)
def _response(toast_type: str, toast_text: str, card_data: dict) -> P2CardActionTriggerResponse:
resp = P2CardActionTriggerResponse()
toast = CallBackToast()
toast.type = toast_type
toast.content = toast_text
resp.toast = toast
card = CallBackCard()
card.type = "raw"
card.data = card_data
resp.card = card
return resp
def _empty_response() -> P2CardActionTriggerResponse:
return P2CardActionTriggerResponse()
try:
event = data.event
if not event:
return _empty_response()
action = event.action
if not action:
return _empty_response()
value: dict = action.value or {}
action_type = value.get("action")
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 _empty_response()
operator_open_id = (event.operator.open_id or "") if event.operator else ""
logger.info(
"Card action: %s for session %s by ...%s",
action_type, conv_id, operator_open_id[-8:],
)
# --- AskUserQuestion answer ---
if action_type == "answer_question":
question = value.get("question", "")
answer = value.get("answer", "")
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_question_answer_async(conv_id, question, answer), _main_loop
)
return _response(
"success", f"已选择: {answer}",
{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "❓ Claude Code 提问"},
"template": "green",
},
"body": {
"elements": [
{"tag": "markdown", "content": f"**{question}**\n\n✅ 已选择: **{answer}**"},
],
},
},
)
# --- Tool approval ---
approved = action_type == "approve"
if _main_loop:
asyncio.run_coroutine_threadsafe(
_handle_approval_async(conv_id, approved), _main_loop
)
if approved:
toast_type, toast_text = "success", "✅ 已批准"
card_status, template = "✅ **已批准**", "green"
else:
toast_type, toast_text = "warning", "❌ 已拒绝"
card_status, template = "❌ **已拒绝**", "red"
return _response(
toast_type, toast_text,
{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
"template": template,
},
"body": {"elements": [{"tag": "markdown", "content": card_status}]},
},
)
except Exception:
logger.exception("Error handling card action")
return P2CardActionTriggerResponse()
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)
async def _handle_question_answer_async(conv_id: str, question: str, answer: str) -> None:
"""Process a question answer from card callback."""
from agent.manager import manager
await manager.answer_question(conv_id, {question: answer})
def build_event_handler() -> lark.EventDispatcherHandler:
"""Construct the EventDispatcherHandler with all registered callbacks."""
handler = (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(_handle_message)
.register_p1_customized_event("im.message.receive_v1", _handle_any)
.register_p2_card_action_trigger(_handle_card_action)
.build()
)
return handler
+4
View File
@@ -21,6 +21,10 @@ OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "")
ANTHROPIC_API_KEY: str = _cfg.get("ANTHROPIC_API_KEY", "")
# SDK approval timeout (seconds) for can_use_tool callback
SDK_APPROVAL_TIMEOUT: int = _cfg.get("SDK_APPROVAL_TIMEOUT", 120)
ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False)
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")
+10 -8
View File
@@ -1,16 +1,18 @@
"""
Root conftest — runs before pytest collects any test files or imports any
production modules. Patches config._CONFIG_PATH to point at the test keyring
so that `import config` never tries to open the real keyring.yaml.
production modules. Creates a temporary keyring.yaml from the test keyring
so that `import config` works without the real keyring.yaml.
Must live at the repo root (not inside tests/) to fire before collection.
"""
import shutil
from pathlib import Path
import importlib
_TEST_KEYRING = Path(__file__).parent / "tests" / "keyring_test.yaml"
_REPO_ROOT = Path(__file__).parent
_TEST_KEYRING = _REPO_ROOT / "tests" / "keyring_test.yaml"
_KEYRING = _REPO_ROOT / "keyring.yaml"
# Patch config before anything else imports it
import config as _config_mod
_config_mod._CONFIG_PATH = _TEST_KEYRING
importlib.reload(_config_mod)
# If the real keyring.yaml doesn't exist, copy the test version so config.py
# can load at module import time. This file is gitignored.
if not _KEYRING.exists() and _TEST_KEYRING.exists():
shutil.copy2(_TEST_KEYRING, _KEYRING)
+160
View File
@@ -0,0 +1,160 @@
"""Example: can_use_tool callback — intercept AskUserQuestion and provide answers.
Demonstrates:
- can_use_tool callback for tool permission control
- Detecting AskUserQuestion tool calls
- Using PermissionResultAllow(updated_input=...) to pre-fill user answers
- Auto-allowing read-only tools
This pattern is used by the secretary model to forward CC questions
to Feishu users and relay their responses back.
Usage:
cd docs/claude
../../.venv/Scripts/python test_can_use_tool_ask.py
"""
from __future__ import annotations
import asyncio
import json
from _sdk_test_common import auth_env, make_tmpdir, remove_tmpdir, setup_auth
# Simulated user responses (in production, these come from Feishu card callbacks)
SIMULATED_ANSWERS: dict[str, str] = {}
permission_log: list[dict] = []
async def permission_callback(tool_name, input_data, context):
"""can_use_tool callback that intercepts AskUserQuestion.
For AskUserQuestion:
- Extracts questions and options from input_data
- Provides pre-filled answers via updated_input
- Returns PermissionResultAllow with the modified input
For other tools:
- Read-only tools: auto-allow
- Write tools: auto-allow (for testing)
"""
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
permission_log.append({
"tool": tool_name,
"input_keys": list(input_data.keys()),
})
if tool_name == "AskUserQuestion":
questions = input_data.get("questions", [])
print(f"\n 🔔 AskUserQuestion intercepted! ({len(questions)} questions)")
# Build answers dict: {question_text: selected_option_label}
answers = {}
for q in questions:
question_text = q.get("question", "")
options = q.get("options", [])
multi = q.get("multiSelect", False)
print(f" Q: {question_text}")
for opt in options:
print(f" - {opt['label']}: {opt.get('description', '')[:60]}")
# In production: send card to Feishu, wait for user selection
# Here: use first option as simulated answer
if options:
selected = SIMULATED_ANSWERS.get(question_text, options[0]["label"])
answers[question_text] = selected
print(f" → Selected: {selected}")
# Pre-fill answers in the tool input via updated_input
modified_input = dict(input_data)
if "answers" not in modified_input:
modified_input["answers"] = {}
if isinstance(modified_input["answers"], dict):
modified_input["answers"].update(answers)
print(f" → updated_input.answers = {answers}")
return PermissionResultAllow(updated_input=modified_input)
# Auto-allow everything else for this test
return PermissionResultAllow()
async def main() -> int:
ok, msg = setup_auth()
print(msg)
if not ok:
return 1
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
SystemMessage,
TextBlock,
ToolUseBlock,
)
tmpdir = make_tmpdir("ask-test-")
try:
print("--- Test: can_use_tool intercepts AskUserQuestion ---")
print(f" tmpdir: {tmpdir}")
opts = ClaudeAgentOptions(
cwd=str(tmpdir),
permission_mode="default",
can_use_tool=permission_callback,
max_turns=5,
env=auth_env(),
)
# Ask Claude to ask the user a question — this will trigger AskUserQuestion
prompt = (
"I need you to ask the user a question using the AskUserQuestion tool. "
"Ask them: 'Which programming language do you prefer?' "
"with options: 'Python' (great for AI), 'TypeScript' (great for web). "
"Then tell me what they chose."
)
print(f"\n Prompt: {prompt[:100]}...")
async with ClaudeSDKClient(opts) as client:
await client.query(prompt)
ask_seen = False
result_text = ""
async for msg in client.receive_response():
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(f" Claude: {block.text[:200]}")
elif isinstance(block, ToolUseBlock):
if block.name == "AskUserQuestion":
ask_seen = True
print(f" [ToolUse] AskUserQuestion called!")
else:
print(f" [ToolUse] {block.name}")
elif isinstance(msg, ResultMessage):
result_text = msg.result or ""
print(f"\n Result: {result_text[:300]}")
print(f"\n Permission log ({len(permission_log)} entries):")
for entry in permission_log:
print(f" {entry['tool']}: keys={entry['input_keys']}")
if ask_seen:
print("\n ✅ PASS: AskUserQuestion was intercepted by can_use_tool")
else:
print("\n ⚠️ AskUserQuestion was not called (Claude may have answered directly)")
print(" This is OK — it means Claude didn't need to ask.")
return 0
finally:
remove_tmpdir(tmpdir)
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+72
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
+253
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)。
+281
View File
@@ -0,0 +1,281 @@
# 卡片 JSON 2.0 结构
本文档介绍卡片 JSON 2.0 的整体结构和属性说明。
## 概念说明
- 卡片 JSON 2.0 是指在卡片 JSON 数据中,声明了 `schema` 属性为 `"2.0"` 的版本。与 1.0 版本相比,2.0 版本有较多不兼容差异和新增属性,详情参考[卡片 JSON 2.0 版本更新说明](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-breaking-changes-release-notes)。
- 在可视化搭建工具中,你可通过搭建[新版卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/feishu-card-cardkit/cardkit-upgraded-version-card-release-notes),获取 2.0 版本的卡片 JSON 源代码。
## 注意事项
- 卡片 JSON 2.0 结构支持飞书客户端 7.20 及之后版本。当使用 JSON 2.0 结构的卡片发送至低于 7.20 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/35efb2f0bfbe5d22fe4b7a420925d2af_g5UivxGopO.png?height=449&lazyload=true&maxWidth=400&width=742)
- 卡片 JSON 2.0 结构暂时仅支持共享卡片,不支持独享卡片配置。即 `update_multi` 参数仅支持设为 `true`
- 卡片 JSON 2.0 结构中,一张卡片最多支持 200 个元素(如 `tag``plain_text` 的文本元素)或组件。
## JSON 结构
以下为卡片 JSON 2.0 的整体结构。
```JSON
{
"schema": "2.0", // 卡片 JSON 结构的版本。默认为 1.0。要使用 JSON 2.0 结构,必须显示声明 2.0。
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"streaming_config": {}, // 流式更新配置。详情参考下文。
"summary": { // 卡片摘要信息。可通过该参数自定义客户端聊天栏消息预览中的展示文案。
"content": "自定义内容", // 自定义摘要信息。如果开启了流式更新模式,该参数将默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考配置卡片多语言文档。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
},
"locales": [ // JSON 2.0 新增属性。用于指定生效的语言。如果配置 locales,则只有 locales 中的语言会生效。
"en_us",
"ja_jp"
],
"enable_forward": true, // 是否支持转发卡片。默认值为 true。
"update_multi": true, // 是否为共享卡片。默认值为 true,JSON 2.0 暂时仅支持设为 true,即更新卡片的内容对所有收到这张卡片的人员可见。
"width_mode": "fill", // 卡片宽度模式。支持 "compact"(紧凑宽度 400px)模式 或 "fill"(撑满聊天窗口宽度)模式。默认不填时的宽度为 600px。
"use_custom_translation": false, // 是否使用自定义翻译数据。默认值 false。为 true 时,在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求翻译,不使用自定义翻译数据。
"enable_forward_interaction": false, // 转发的卡片是否仍然支持回传交互。默认值 false。
"style": { // 添加自定义字号和颜色。可应用在组件 JSON 数据中,设置字号和颜色属性。
"text_size": { // 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在组件 JSON 中设置字号属性。支持添加多个自定义字号对象。
"cus-0": {
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
"pc": "medium", // 桌面端的字号。
"mobile": "large" // 移动端的字号。
}
},
"color": { // 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。
"cus-0": {
"light_mode": "rgba(5,157,178,0.52)", // 浅色主题下的自定义颜色语法
"dark_mode": "rgba(78,23,108,0.49)" // 深色主题下的自定义颜色语法
}
}
}
},
"card_link": {
// 指定卡片整体的跳转链接。
"url": "https://www.baidu.com", // 默认链接地址。未配置指定端地址时,该配置生效。
"android_url": "https://developer.android.com/",
"ios_url": "https://developer.apple.com/",
"pc_url": "https://www.windows.com"
},
"header": {
"title": {
// 卡片主标题。必填。要为标题配置多语言,参考配置卡片多语言文档。
"tag": "plain_text", // 文本类型的标签。可选值:plain_text 和 lark_md。
"content": "示例标题" // 标题内容。
},
"subtitle": {
// 卡片副标题。可选。
"tag": "plain_text", // 文本类型的标签。可选值:plain_text 和 lark_md。
"content": "示例文本" // 标题内容。
},
"text_tag_list": [
// 标题后缀标签,最多设置 3 个 标签,超出不展示。可选。
{
"tag": "text_tag",
"element_id": "custom_id", // 操作元素的唯一标识。用于在调用组件相关接口中指定元素。需开发者自定义。
"text": {
// 标签内容
"tag": "plain_text",
"content": "标签 1"
},
"color": "neutral" // 标签颜色
}
],
"i18n_text_tag_list": {
// 多语言标题后缀标签。每个语言环境最多设置 3 个 tag,超出不展示。可选。同时配置原字段和国际化字段,优先生效多语言配置。
"zh_cn": [],
"en_us": [],
"ja_jp": [],
"zh_hk": [],
"zh_tw": []
},
"template": "blue", // 标题主题样式颜色。支持 "blue"|"wathet"|"turquoise"|"green"|"yellow"|"orange"|"red"|"carmine"|"violet"|"purple"|"indigo"|"grey"|"default"。默认值 default。
"icon": { // 前缀图标。
"tag": "standard_icon", // 图标类型。
"token": "chat-forbidden_outlined", // 图标的 token。仅在 tag 为 standard_icon 时生效。
"color": "orange", // 图标颜色。仅在 tag 为 standard_icon 时生效。
"img_key": "img_v2_38811724" // 图片的 key。仅在 tag 为 custom_icon 时生效。
},
"padding": "12px 8px 12px 8px" // 标题组件的内边距。JSON 2.0 新增属性。默认值 "12px",支持范围 [0,99]px。
},
"body": { // 卡片正文。
// JSON 2.0 新增布局类属性,用于控制子元素排列:
"direction": "vertical", // 正文或容器内组件的排列方向。可选值:"vertical"(垂直排列)、"horizontal"(水平排列)。默认为 "vertical"。
"padding": "12px 8px 12px 8px", // 正文或容器内组件的内边距,支持范围 [0,99]px。
"horizontal_spacing": "3px", // 正文或容器内组件的水平间距,可选值:"small"(4px)、"medium"(8px)、"large"(12px)、"extra_large"(16px)或[0,99]px。
"horizontal_align": "left", // 正文或容器内组件的水平对齐方式,可选值:"left"、"center"、"right"。默认值为 "left"。
"vertical_spacing": "4px", // 正文或容器内组件的垂直间距,可选值:"small"(4px)、"medium"(8px)、"large"(12px)、"extra_large"(16px)或[0,99]px。
"vertical_align": "center", // 正文或容器内组件的垂直对齐方式,可选值:"top"、"center"、"bottom",默认值为 "top"。
"elements": [ // 在此传入各个组件的 JSON 数据,组件将按数组顺序纵向流式排列。
{
"tag": "xxx", // 组件的标签。
"margin": "4px", // 组件的外边距,默认值 "0",支持范围 [-99,99]px。JSON 2.0 新增属性。
"element_id": "custom_id" // 操作组件的唯一标识。JSON 2.0 新增属性。用于在调用流式更新相关接口中指定组件。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
}
]
}
}
```
## 属性说明
本小节介绍卡片结构中的属性。
### 全局属性
卡片全局属性包括以下字段。
```JSON
{
"schema": "2.0",
"config": {},
"card_link": {},
"header": {},
"body": {
"elements": []
}
}
```
各个字段说明如下所示。
若这些字段均不传,则卡片 JSON 为 "{}"。飞书开放平台支持发送卡片 JSON 为 "{}" 的空白卡片。
字段 | 是否必填 | 描述
---|---|---
schema | 否 | 卡片结构的版本声明。默认为 1.0 版本。要使用 JSON 2.0 结构,必须显示声明 2.0。可选值:<br>- 1.0:卡片 JSON 1.0 结构。详情参考[卡片 JSON 1.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-structure)。<br>- 2.0:卡片 JSON 2.0 结构。支持更多字段和能力,如卡片流式更新能力、富文本组件(markdown)更多语法等。详情参考[卡片 JSON 2.0 不兼容变更&更新说明](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-breaking-changes-release-notes)。
config | 否 | 配置卡片的全局行为,包括流式更新模式(JSON 2.0 新增能力)、是否允许被转发、是否为共享卡片等。
card_link | 否 | 指定卡片整体的点击跳转链接。你可以配置一个默认链接,也可以分别为 PC 端、Android 端、iOS 端配置不同的跳转链接。
header | 否 | 标题组件相关配置。详情参考[标题](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/title)组件。
body | 否 | 卡片正文,包含一个名为 elements 的数组,用于放置各类组件。
### 卡片全局行为设置 `config`
`config` 用于配置卡片的全局行为,包括流式更新模式、是否允许被转发、是否为共享卡片等。
```json
{
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"streaming_config": { // 流式更新配置。
"print_frequency_ms": { // // 流式更新频率,单位:ms
"default": 30,
"android": 25,
"ios": 40,
"pc": 50
},
"print_step": { // // 流式更新步长,单位:字符数
"default": 2,
"android": 3,
"ios": 4,
"pc": 5
},
"print_strategy": "fast" // 流式更新策略,枚举值,可取:fast/delay
},
"summary": { // 卡片摘要信息。可通过该参数自定义客户端聊天栏消息预览中的展示文案。
"content": "自定义内容", // 自定义摘要信息。如果开启了流式更新模式,该参数将默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考配置卡片多语言文档。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
},
"locales": [ // JSON 2.0 新增属性。用于指定生效的语言。如果配置 locales,则只有 locales 中的语言会生效。
"en_us",
"ja_jp"
], // 卡片支持的语言列表。
"enable_forward": true, // 是否支持转发卡片。默认值为 true。
"update_multi": true, // 是否为共享卡片。默认值为 true,JSON 2.0 暂时仅支持设为 true,即更新卡片的内容对所有收到这张卡片的人员可见。
"width_mode": "fill", // 卡片宽度模式。支持 "compact"(紧凑宽度 400px)模式 或 "fill"(撑满聊天窗口宽度)模式。默认不填时的宽度为 600px。
"use_custom_translation": false, // 是否使用自定义翻译数据。默认值 false。为 true 时,在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求翻译,不使用自定义翻译数据。
"enable_forward_interaction": false, // 转发的卡片是否仍然支持回传交互。默认值 false。
"style": { // 添加自定义字号和颜色。可应用在组件 JSON 数据中,设置字号和颜色属性。
"text_size": { // 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在组件 JSON 中设置字号属性。支持添加多个自定义字号对象。
"cus-0": {
"default": "medium", // 在无法差异化配置字号的旧版飞书客户端上,生效的字号属性。选填。
"pc": "medium", // 桌面端的字号。
"mobile": "large" // 移动端的字号。
}
},
"color": { // 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。
"cus-0": {
"light_mode": "rgba(5,157,178,0.52)", // 浅色主题下的自定义颜色语法
"dark_mode": "rgba(78,23,108,0.49)" // 深色主题下的自定义颜色语法
}
}
}
}
}
```
`config` 下的各字段说明如下表所示。
字段名称 | 是否必填 | 类型 | 默认值 | 说明
---|---|---|---|---
streaming_mode | 否 | Boolean | false | 卡片是否处于流式更新模式。详情参考[流式更新卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
streaming_config | 否 | object | / | 流式更新相关配置。详情参考[流式更新卡片](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
summary | 否 | Object | / | 自定义摘要信息配置。即飞书客户端聊天栏消息预览中的文案。
content | 否 | String | 无 | 摘要文本。当 `streaming_mode``true` 时,该字段默认为“生成中”。支持自定义。
i18n_content | 否 | Object | / | 摘要文本的多语言配置。详情参考[局部国际化配置](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/configure-multi-language-content)。
enable_forward | 否 | Boolean | true | 是否允许转发卡片。取值:<br>- true:允许<br>- false:不允许
update_multi | 否 | Boolean | true | 是否为共享卡片。取值:<br>- true:是共享卡片,更新卡片的内容对所有收到这张卡片的人员可见。<br>- false:非共享卡片,仅操作用户可见卡片的更新内容。
width_mode | 否 | String | default | 卡片宽度模式。取值:<br>- default:默认宽度。PC 端宽版、iPad 端上的宽度上限为 600px。<br>- compact:紧凑宽度 400px<br>- fill:自适应屏幕宽度<br>注意:卡片搭建工具上暂时不支持 `width_mode` 属性。
use_custom_translation | 否 | Boolean | false | 是否使用自定义翻译数据。取值:<br>- true:在用户点击消息翻译后,使用 i18n 对应的目标语种作为翻译结果。若 i18n 取不到,则使用当前内容请求飞书的机器翻译。<br>- false:不使用自定义翻译数据,直接请求飞书的机器翻译。
enable_forward_interaction | 否 | Boolean | false | 转发的卡片是否仍然支持回传交互。
style | 否 | Object | 空 | 添加自定义字号和颜色。可应用于组件的 JSON 数据中,设置字号和颜色属性。
text_size | 否 | Object | 空 | 分别为移动端和桌面端添加自定义字号,同时添加兜底字号。用于在普通文本组件和富文本组件 JSON 中设置字号属性。支持添加多个自定义字号对象。详情参考[普通文本](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/plain-text)组件和[富文本(Markdown](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/rich-text)组件。
color | 否 | Object | 空 | 分别为飞书客户端浅色主题和深色主题添加 RGBA 语法。用于在组件 JSON 中设置颜色属性。支持添加多个自定义颜色对象。详情参考[颜色枚举值](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/enumerations-for-fields-related-to-color)。
### 卡片全局跳转链接 `card_link`
`card_link` 字段用于指定卡片整体的点击跳转链接。你可以配置一个默认链接,也可以分别为 PC 端、Android 端、iOS 端配置不同的跳转链接。
```json
"card_link": {
// 指定卡片整体的跳转链接。
"url": "https://www.baidu.com", // 默认链接地址。未配置指定端地址时,该配置生效。
"android_url": "https://developer.android.com/",
"ios_url": "https://developer.apple.com/",
"pc_url": "https://www.windows.com"
}
```
card_link 下的各字段说明如下表所示。
**注意事项****注意**
- url 和各端的链接(android_url、ios_url、pc_url)必填其中一个。如果不填写 url,则必须完整填写 android_url、ios_url、pc_url 三个字段。如果同时填写了 url 和 android_url、ios_url、pc_urlurl 字段生效。
- 如果需要禁止某端进行跳转,可以将对应的参数值配置为 `lark://msgcard/unsupported_action`
字段名称 | 是否必填 | 类型 | 说明
---|---|---|---
url | 否 | String | 默认的链接地址。
pc_url | 否 | String | PC 端的链接地址。
ios_url | 否 | String | iOS 端的链接地址。
android_url | 否 | String | Android 端的链接地址。
### 卡片标题 `header`
`header` 字段用于配置卡片的标题。了解`header` 字段说明,参见[标题组件](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/title)。
```json
"header": {} // 卡片标题
```
### 卡片正文 `body`
在卡片的`body`字段中,你需要添加卡片组件作为卡片正文内容,组件将按数组顺序纵向流式排列。了解卡片组件,参考[卡片 JSON 2.0 版本组件概述](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/component-json-v2-overview)。
在卡片 JSON 2.0 结构中,所有组件(标题组件除外)和元素(如 tag 为 plain_text 的文本元素)新增 element_id 属性,作为操作组件或元素的唯一标识。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
```json
{
"body": { // 卡片正文。
"elements": [ // 在此传入各个组件的 JSON 数据,组件将按数组顺序纵向流式排列。
{
"tag": "xxx", // 组件的标签。
"element_id": "custom_id" // 操作组件的唯一标识。
}
]
}
}
```
@@ -0,0 +1,239 @@
# 卡片 JSON 2.0 版本更新说明
本文档介绍卡片 JSON 2.0 版本与 1.0 版本结构之间的不兼容变更和优化说明。了解完整的 JSON 2.0 结构数据,参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)。
## 注意事项
- 卡片 JSON 2.0 结构支持飞书客户端 7.20 及之后版本。当使用 JSON 2.0 结构的卡片发送至低于 7.20 版本的客户端时,卡片标题可正常显示,但内容将展示兜底的升级提示文案。
![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/35efb2f0bfbe5d22fe4b7a420925d2af_g5UivxGopO.png?height=449&lazyload=true&maxWidth=400&width=742)
- 卡片 JSON 2.0 结构暂时仅支持共享卡片,不支持独享卡片配置。即 `update_multi` 参数仅支持设为 `true`
## 不兼容变更
本小节介绍卡片 JSON 2.0 版本相对于 1.0 版本所发生的不兼容变更。
### 卡片交互有效期变更
- 1.0 结构:发出卡片的可交互时间为 30 天,可更新时间为 14 天(如果在第 14-30 天交互卡片,且交互回调动作为更新卡片,更新动作将不会生效)。
- 2.0 结构:卡片可交互和可更新时间统一为 14 天。
### 属性校验变更
在 JSON 2.0 版本中,传入不支持的属性将报错。
| **1.0 结构** | **2.0 结构** |
| ------------- | ----------- |
| 传入不支持的属性作忽略处理 | 传入不支持的属性将报错 |
### JSON 全局结构和字段变更
- **结构变更**
- JSON 2.0 版本新增 `body` 字段,`elements` 属性放置在 `body` 层级下。了解 2.0 整体结构,参考[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)。
- JSON 2.0 版本不再支持通过 `i18n_elements` 字段设置全局多语言。你可通过 `i18n_content` 等局部多语言字段实现组件级别的多语言配置,详情参考[配置卡片多语言](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/configure-multi-language-content)。
- **`fallback`** **字段变更**
JSON 2.0 版本暂不支持使用 `fallback` 字段配置自定义的全局降级规则。
- **默认值变更**
JSON 2.0 版本中的 `update_multi` 默认值变更为 `true`,且暂时仅支持设为 `true``update_multi` 属性用于设置卡片是否为共享卡片;`true` 表示设置卡片为共享卡片,更新卡片的内容对所有收到这张卡片的人员可见;`false` 表示设置卡片为独享卡片,更新卡片的内容对他人不可见。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"schema": "1.0", // 不填默认为 1.0<br>"config": {<br>"update_multi": false // 默认值为 false。<br>},<br>"card_link": {},<br>"header": {},<br>"i18n_header": {},<br>"elements": [],<br>"i18n_elements": {},<br>"fallback": {}<br>}<br>``` | ```json<br>{<br>"schema": "2.0", // 2.0 需主动声明<br>"config": {<br>"update_multi": true // 默认值为 true,且暂时仅支持设为 `true`<br>},<br>"card_link": {},<br>"header": {},<br>"body": { // 新增 body 字段,elements 属性放置在 body 层级下。<br>"elements": [] // 不再支持 i18n_elements 字段<br>},<br>"fallback": {}<br>}<br>```
### 容器类组件布局属性默认值变更
- [表单容器](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/form-container)的 `vertical_spacing` 和 `horizontal_spacing` 字段的默认值由 `16px` 改为 `12px`,且支持开发者自定义配置。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"margin": "0", // 容器的外边距设置。<br>"padding": "0", // 容器的内边距设置。<br>"vertical_spacing": "16px", // 容器内组件的垂直边距设置。<br>"horizontal_spacing": "16px" // 容器内组件的水平边距设置。<br>}<br>``` | ```json<br>{<br>"margin": "0",<br>"padding": "0",<br>"vertical_spacing": "12px", // 默认值变更,且支持自定义。<br>"horizontal_spacing": "12px" // 默认值变更,且支持自定义。<br>}<br>```
<br>
- [交互容器](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/interactive-container)的 `vertical_spacing` 和 `horizontal_spacing` 字段的默认值由 `12px` 改为 `4px` 和 `8px`,且支持开发者自定义配置。
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"margin": "0", // 容器的外边距设置。<br>"padding": "4px 12px", // 容器的内边距设置。<br>"vertical_spacing": "12px", // 容器内组件的垂直边距设置。<br>"horizontal_spacing": "12px" // 容器内组件的水平边距设置。<br>}<br>``` | ```json<br>{<br>"margin": "0",<br>"padding": "4px 12px",<br>"vertical_spacing": "4px", // 默认值变更,且支持自定义。<br>"horizontal_spacing": "8px" // 默认值变更,且支持自定义。<br>}<br>```
<br>
- [折叠面板](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/containers/collapsible-panel)的 `padding` 字段默认值变更:
- 当折叠面板配置了边框(border)或背景色(background_color)时,标题区 `padding` 字段的默认值变更为上下边距 4px,左右边距 8px。
1.0 结构 | 2.0 结构
---|---
```json<br>// 有边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "8px" // 标题区的内边距。<br>},<br>"margin": "0",<br>"padding": "8px",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}<br>``` | ```json<br>// 有边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "4px 8px" // 标题区的内边距默认值变更。上下边距为 4px,左右边距为 8px。<br>},<br>"margin": "0",<br>"padding": "8px",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}<br>```
- 当折叠面板未配置边框(border)或背景色(background_color)时,标题区 `padding` 字段的默认值变更为 0,内容区的内边距默认值变更为上边距 8px,右、下、左边距 0。
1.0 结构 | 2.0 结构
---|---
```json<br>// 无边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "8px 0 8px 0" // 标题区的内边距。<br>},<br>"margin": "0",<br>"padding": "0",<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>} | ```json<br>// 无边框(border)或背景色(background_color)时<br>{<br>"header": {<br>"margin": "0",<br>"padding": "0" // 标题区的内边距默认值变更<br>},<br>"margin": "0",<br>"padding": "8px 0 0 0", // 内容区的内边距默认值变更。上边距为 8px,右、下、左边距为 0px。<br>"vertical_spacing": "8px",<br>"horizontal_spacing": "8px"<br>}
### `vertical_spacing` 和 `horizontal_spacing` 枚举值 & 映射数值变更
1.0 结构 | 2.0 结构
---|---
<code>vertical_spacing</code> 和 <code>horizontal_spacing</code>字段的枚举和对应的值为:<br>- small4px<br>- medium8px<br>- large16px | <code>vertical_spacing</code> 和 <code>horizontal_spacing</code>字段的枚举和对应的值为:<br>- small4px<br>- medium8px<br>- large12px<br>- extra_large16px
### 标题组件配置变更
- 标题组件的 icon 配置结构变更,对齐其它组件:
1.0 结构 | 2.0 结构
---|---
```json<br>{<br>"header": {<br>"title": {},<br>"icon": {<br>"img_key": "img_v2_38811724" <br>},<br>"ud_icon": {<br>"token": "chat-forbidden_outlined", <br>"style": {<br>"color": "red"<br>}<br>}<br>}<br>}<br>``` | ```json<br>{<br>"header": {<br>"title": {},<br>"icon": {<br>"tag": "standard_icon",<br>"token": "chat-forbidden_outlined",<br>"color": "orange", <br>"img_key": "img_v2_38811724"<br>}<br>}<br>}<br>```
### 图片组件不再支持通栏配置
1.0 结构 | 2.0 结构
---|---
支持 stretch_without_padding 通栏配置,图片的宽度将撑满卡片宽度。<br>```json<br>{<br>"tag": "img",<br>"img_key": "img_v3_0238_073f1823-df2b-4377-86c6-e293f183622j",<br>"size": "stretch_without_padding" // 支持通栏配置,图片宽度将撑满卡片宽度。<br>}<br>``` | 不再支持通栏配置,但可设置 margin 字段为负数实现通栏效果。<br>```json<br>{<br>"tag": "img",<br>"img_key": "img_v3_0238_073f1823-df2b-4377-86c6-e293f183622j",<br>"size": "crop_center",<br>"margin": "4px -12px"<br>}<br>```
### 富文本(Markdown)组件废弃差异化跳转语法
2.0 结构不再支持以下差异化跳转语法。你可使用`<link></link>` 标签替代,如 `<link icon='chat_outlined' url='``https://applink.feishu.cn/client/chat/xxxxx'`` pc_url='' ios_url='' android_url=''> `` 战略研讨会 </link> `
```json
{
"tag": "markdown",
"href": {
"urlVal": {
"url": "xxx",
"pc_url": "xxx",
"ios_url": "xxx",
"android_url": "xxx"
}
},
"content": "[差异化跳转]($urlVal)"
}
```
### 兜底高度 & 宽度变更
1.0 结构 | 2.0 结构
---|---
- 卡片兜底高度:24px<br>- 组件宽度设置的像素值如果大于父容器宽度,会收缩限制到父容器宽度,仅在交互容器中会截断展示 | - 卡片的兜底高度:40px<br>- 组件宽度设置的像素值如果大于父容器宽度,将会截断展示
### 废弃备注组件 & 交互模块
2.0 结构不再支持[备注](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/note)note)组件和[交互模块](https://open.feishu.cn/document/ukTMukTMukTM/uYzM3QjL2MzN04iNzcDN/component-list/common-components-and-elements)"tag" 为 "action"),相关效果可由以下组件和属性实现:
- 备注(note)组件:可由普通文本组件配置 notation 字号、grey 字体颜色、icon 属性替代;
- 交互模块:可由按钮(button)或折叠按钮组(overflow)组件配置合适的组件间距 (`vertical_spacing``horizontal_spacing`) 替代。
## 新增属性和优化说明
本小节介绍 2.0 结构新增的属性和优化点。
### 新增 `streaming_mode` 属性,支持流式更新
2.0 结构新增 `streaming_mode``summary` 字段,支持卡片流式更新、文本流式更新能力。详情参考[流式更新 OpenAPI 调用指南](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/streaming-updates-openapi-overview)。
```json
{
"schema": "2.0", // 卡片 JSON 结构的版本。默认为 1.0。
"config": {
"streaming_mode": true, // 卡片是否处于流式更新模式,默认值为 false。
"summary": {
"content": "自定义内容", // 自定义摘要信息。默认为“生成中”。
"i18n_content": { // 摘要信息的多语言配置。了解支持的所有语种。参考。
"zh_cn": "",
"en_us": "",
"ja_jp": ""
}
}
}
}
```
### 新增 `element_id` 属性,用于操作组件
所有组件和元素(如 `tag``plain_text` 的文本元素)新增 `element_id` 属性,作为操作组件或元素的唯一标识。在同一张卡片内,该字段的值全局唯一。仅允许使用字母、数字和下划线,必须以字母开头,不得超过 20 字符。
```json
{
"tag": "button", // 组件的标签。
"element_id": "button_1" // 操作组件时的唯一标识。
}
```
### 组件统一支持布局相关能力
卡片 JSON 2.0 结构中,各类组件统一新增了一批布局类属性。
```json
// 卡片层级
{
"schema": "2.0",
"header": {
"title": {},
"padding": "4px" // 支持设置[0,99]px
},
"body": {
"vertical_spacing": "4px", // body 内子组件的垂直间距,支持设置[0,99]px
"padding": "4px", // body 的内边距配置,支持设置[0,99]px
"elements": []
}
}
// 组件
{
"tag": "xxxx",
// 各组件均新增的布局类属性
"margin": "4px", // 外边距,默认值 "0",支持范围 [-99,99]px
// 容器类组件(含elements)新增的布局类属性,用于控制子元素排列
"padding": "4px", // 内边距,支持范围 [0,99]px
"direction": "vertical", // 布局方向,支持 "vertical"|"horizontal",默认值 "vertical"
"horizontal_spacing": "3px", // 水平间距,支持范围 [0,99]px
"vertical_spacing": "4px", // 垂直间距,支持范围 [0,99]px
"horizontal_align": "left", // 水平对齐,支持 "left"|"center"|"right",默认值 "left"
"vertical_align": "center", // 垂直对齐,支持 "top"|"center"|"bottom",默认值 "top"
// 其他
"elements": []
}
```
[普通文本组件](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/plain-text)支持配置 width 属性。可取值:
- fill:文本的宽度将与组件宽度一致,撑满组件。
- auto:文本的宽度自适应文本内容本身的长度。
- [16,999]px:自定义文本宽度。
```json
{
"tag": "div",
"width": "fill", // 文本宽度。支持 "fill"|"auto"|"{{[16,999]}}px"。默认值为 fill。
}
```
### 富文本组件支持标准 markdown 语法
[卡片 JSON 2.0 结构](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-structure)支持除 `HTMLBlock` 外所有标准的 Markdown 语法和部分 HTML 语法。了解 Markdown 标准语法,请参考 [CommonMark Spec 官方文档](https://spec.commonmark.org/0.31.2/)。你也可以使用 [CommonMark playground](https://spec.commonmark.org/dingus/) 预览 Markdown 效果。了解更多,参考[富文本(Markdown](https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/rich-text)。
注意,在卡片的富文本组件中,以下语法的渲染效果与 CommonMark 有差异:
- 富文本组件支持使用一个 Enter 键作为软换行(Soft Break);支持两个 Enter 键作为硬换行(Hard Break)。软换行在渲染时可能会被忽略,具体取决于渲染器如何处理;硬换行在渲染时始终会显示为一个新行。
- 2.0 结构支持以下 HTML 语法:
- 开标签 `<br>`
- 自闭合标签 `<br/>`
- 开标签 `<hr>`
- 自闭合标签 `<hr/>`
- 闭合标签 `<person></person>`
- 闭合标签 `<local_datetime></local_datetime>`
- 闭合标签 `<at></at>`
- 闭合标签 `<a></a>`
- 闭合标签 `<text_tag></text_tag>`
- 闭合标签 `<raw></raw>`
- 闭合标签 `<link></link>`
- 闭合标签 `</font>`,支持嵌套其它标签,如 `<font color=red>red<font color=green>greenagain</font>`。其它标签包括:
- 闭合标签 `<local_datetime></local_datetime>`
- 闭合标签 `<at></at>`
- 闭合标签 `<a></a>`
- 闭合标签 `<link></link>`
- 闭合标签 `<font></font>`
### 容器类组件新增可内嵌的组件类型
JSON 2.0 结构中,表单容器、交互容器、折叠面板、分栏组件可内嵌除表单容器和表格组件外的其它所有组件。
1.0 结构 | 2.0 结构
---|---
- 表单容器:不支持内嵌表格、图表、和表单容器组件;不可直接内嵌普通文本组件<br>- 交互容器:仅支持内嵌普通文本、富文本、图片、备注、分栏、勾选器、交互容器组件<br>- 折叠面板:不支持内嵌表单容器(form)和表格组件(table)组件<br>- 分栏:不支持内嵌表格(table)、表单(form)和多图混排(img_combination)组件 | 表单容器、交互容器、折叠面板、分栏组件可内嵌除表单容器(form)和表格组件(table)外的其它所有组件。
+17 -3
View File
@@ -49,7 +49,7 @@ Bot command prefix: {prefix}
- Close/switch sessions: "关掉第一个" → run_command("{prefix}close 1")
- Change routing mode: "切换到直连模式" → run_command("{prefix}direct")
- Set a reminder: "10分钟后提醒我" → run_command("{prefix}remind 10m 提醒我")
- Check status: "看看现在有哪些 session" → run_command("{prefix}status")
- Check status: "看看现在有哪些 session" → run_command("{prefix}list")
- Any other command the user would type manually
Available bot commands (pass verbatim to run_command):
@@ -59,7 +59,7 @@ Available bot commands (pass verbatim to run_command):
{prefix}perm <mode> [conv_id] — permission mode: default, edit, plan, bypass
{prefix}direct — direct mode (bypass LLM for CC messages)
{prefix}smart — smart mode (LLM routing, default)
{prefix}status — list sessions
{prefix}list — list sessions
{prefix}remind <Ns|Nm|Nh> <msg> — one-shot reminder
{prefix}tasks — list background tasks
@@ -76,6 +76,18 @@ NEVER use `run_shell` for bot control. NEVER use `run_command` for shell command
5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`.
## Progress queries
When the user asks about task progress (e.g. "怎么样了?" "做得如何?" "进度"),
use `session_progress` with the active conv_id:
- busy=true → summarize recent_tools to tell the user what CC is doing
- busy=false + last_result → summarize the result
- pending_approval is not empty → remind the user to approve/deny
- error is not empty → report the error details
## Passthrough mode
Direct mode sends messages straight to the Claude Code session (no LLM overhead).
The user gets an immediate "executing" confirmation; results are pushed to Feishu on completion.
Guidelines:
- Relay Claude Code's output verbatim.
- If no active session and the user sends a task without naming a directory, ask which project.
@@ -159,7 +171,9 @@ class OrchestrationAgent:
# Passthrough mode: if enabled and active session, bypass LLM
if self._passthrough[user_id] and active_conv:
try:
reply = await manager.send(active_conv, text, user_id=user_id, direct=True)
from orchestrator.tools import get_current_chat
chat_id = get_current_chat()
reply = await manager.send_message(active_conv, text, user_id=user_id, chat_id=chat_id)
logger.info("<<< [passthrough] reply: %r", reply[:120])
return reply
except KeyError:
+65 -5
View File
@@ -83,7 +83,6 @@ class CreateConversationInput(BaseModel):
)
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
idle_timeout: Optional[int] = Field(None, description="Idle timeout in seconds (default 1800)")
cc_timeout: Optional[float] = Field(None, description="Claude Code execution timeout in seconds (default 300)")
class SendToConversationInput(BaseModel):
@@ -108,23 +107,24 @@ class CreateConversationTool(BaseTool):
)
args_schema: Type[BaseModel] = CreateConversationInput
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
try:
resolved = _resolve_dir(working_dir)
except ValueError as exc:
return json.dumps({"error": str(exc)})
user_id = get_current_user()
chat_id = get_current_chat()
conv_id = str(uuid.uuid4())[:8]
await manager.create(
conv_id,
str(resolved),
owner_id=user_id or "",
idle_timeout=idle_timeout or 1800,
cc_timeout=cc_timeout or 300.0,
chat_id=chat_id,
)
result: dict = {
@@ -154,8 +154,9 @@ class SendToConversationTool(BaseTool):
async def _arun(self, conv_id: str, message: str) -> str:
user_id = get_current_user()
chat_id = get_current_chat()
try:
output = await manager.send(conv_id, message, user_id=user_id)
output = await manager.send_and_wait(conv_id, message, user_id=user_id, chat_id=chat_id)
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
except KeyError:
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
@@ -752,12 +753,71 @@ class RunCommandTool(BaseTool):
return result
class SessionProgressInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID to check progress")
class SessionProgressTool(BaseTool):
name: str = "session_progress"
description: str = (
"Check the progress of a running Claude Code session. "
"Returns: busy status, elapsed time, recent tool calls, "
"recent text output, and any pending approval requests. "
"Use this when the user asks about task status or progress."
)
args_schema: Type[BaseModel] = SessionProgressInput
def _run(self, conv_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str) -> str:
user_id = get_current_user()
progress = manager.get_progress(conv_id, user_id)
if progress is None:
return json.dumps({"error": f"Session {conv_id} not found"})
return json.dumps({
"busy": progress.busy,
"elapsed_seconds": int(progress.elapsed_seconds),
"current_prompt": progress.current_prompt[:100],
"recent_text": progress.text_messages[-3:],
"recent_tools": progress.tool_calls[-5:],
"last_result": progress.last_result[:500] if not progress.busy else "",
"error": progress.error,
"pending_approval": progress.pending_approval,
}, ensure_ascii=False)
class InterruptConversationInput(BaseModel):
conv_id: str = Field(..., description="Conversation ID to interrupt")
class InterruptConversationTool(BaseTool):
name: str = "interrupt_conversation"
description: str = "Interrupt a running Claude Code task in a session."
args_schema: Type[BaseModel] = InterruptConversationInput
def _run(self, conv_id: str) -> str:
raise NotImplementedError("Use async version")
async def _arun(self, conv_id: str) -> str:
user_id = get_current_user()
try:
success = await manager.interrupt(conv_id, user_id)
return "Interrupted" if success else "No active task to interrupt"
except KeyError:
return f"Session {conv_id} not found"
except PermissionError as e:
return str(e)
# Module-level tool list for easy import
TOOLS = [
CreateConversationTool(),
SendToConversationTool(),
ListConversationsTool(),
CloseConversationTool(),
SessionProgressTool(),
InterruptConversationTool(),
RunCommandTool(),
ShellTool(),
FileReadTool(),
+26 -79
View File
@@ -1,69 +1,56 @@
"""
Master test fixtures for PhoneWork BDD tests.
Shared test fixtures for PhoneWork tests.
"""
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
TESTS_DIR = Path(__file__).parent
CASSETTES_DIR = TESTS_DIR / "cassettes"
CASSETTES_DIR.mkdir(exist_ok=True)
# ── Feishu send mock ─────────────────────────────────────────────────────────
@pytest.fixture
def feishu_calls():
"""
Capture all calls to bot.feishu send functions.
Lazy imports inside commands.py pull from bot.feishu at call time,
so patching the module attributes is sufficient.
"""
captured: dict[str, list] = {"texts": [], "cards": [], "files": []}
"""Capture all calls to bot.feishu send functions."""
captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []}
async def mock_send_text(receive_id, receive_id_type, text):
captured["texts"].append({"receive_id": receive_id, "text": text})
captured["texts"].append(text)
async def mock_send_markdown(receive_id, receive_id_type, content):
captured["markdowns"].append(content)
async def mock_send_card(receive_id, receive_id_type, card):
captured["cards"].append({"receive_id": receive_id, "card": card})
captured["cards"].append(card)
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
captured["files"].append({"receive_id": receive_id, "file_path": file_path})
captured["files"].append(file_path)
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
patch("bot.feishu.send_markdown", side_effect=mock_send_markdown), \
patch("bot.feishu.send_card", side_effect=mock_send_card), \
patch("bot.feishu.send_file", side_effect=mock_send_file):
patch("bot.feishu.send_file", side_effect=mock_send_file), \
patch("bot.handler.send_text", side_effect=mock_send_text), \
patch("bot.handler.send_markdown", side_effect=mock_send_markdown):
yield captured
# ── run_claude mock ──────────────────────────────────────────────────────────
@pytest.fixture
def mock_run_claude():
"""
Replace run_claude in both its definition and its import site in manager.py.
Default return value is a short CC-style output string.
"""
mock = AsyncMock(return_value="Claude Code: task complete.")
with patch("agent.cc_runner.run_claude", mock), \
patch("agent.manager.run_claude", mock):
yield mock
# ── Singleton state resets ───────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def reset_manager():
def reset_manager(tmp_path):
from agent.manager import manager
import agent.manager as mgr_mod
# Redirect persistence to tmp_path
original_file = mgr_mod.PERSISTENCE_FILE
mgr_mod.PERSISTENCE_FILE = tmp_path / "sessions.json"
manager._sessions.clear()
yield
manager._sessions.clear()
mgr_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True)
@@ -89,8 +76,12 @@ def reset_task_runner():
@pytest.fixture(autouse=True)
def reset_scheduler():
def reset_scheduler(tmp_path):
from agent.scheduler import scheduler
import agent.scheduler as sched_mod
# Redirect persistence to tmp_path so tests don't pollute production data
original_file = sched_mod.PERSISTENCE_FILE
sched_mod.PERSISTENCE_FILE = tmp_path / "scheduled_jobs.json"
for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel()
scheduler._jobs.clear()
@@ -98,6 +89,7 @@ def reset_scheduler():
for task in list(getattr(scheduler, "_tasks", {}).values()):
task.cancel()
scheduler._jobs.clear()
sched_mod.PERSISTENCE_FILE = original_file
@pytest.fixture(autouse=True)
@@ -110,13 +102,6 @@ def reset_contextvars():
set_current_chat(None)
@pytest.fixture(autouse=True)
def reset_reply(pytestconfig):
"""Clear _reply before each test so stale values don't leak between scenarios."""
pytestconfig._reply = None
yield
# ── Working directory isolation ──────────────────────────────────────────────
@pytest.fixture
@@ -127,41 +112,3 @@ def tmp_working_dir(tmp_path, monkeypatch):
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
(tmp_path / "myproject").mkdir()
return tmp_path
# ── VCR cassette factory ─────────────────────────────────────────────────────
def make_vcr_cassette(cassette_name: str):
"""
Return a vcrpy context manager for the given cassette name.
Set VCR_RECORD_MODE=new_episodes locally to record; CI uses 'none'.
Authorization headers are stripped so cassettes are safe to commit.
If the cassette doesn't exist in 'none' mode, the test is skipped.
"""
import os
try:
import vcr
except ImportError:
import pytest
pytest.skip("vcrpy not installed")
record_mode = os.environ.get("VCR_RECORD_MODE", "none")
cassette_path = CASSETTES_DIR / cassette_name
cassette_path.parent.mkdir(parents=True, exist_ok=True)
if record_mode == "none" and not cassette_path.exists():
import pytest
pytest.skip(f"No cassette recorded yet: {cassette_name}. Run with VCR_RECORD_MODE=new_episodes to record.")
my_vcr = vcr.VCR(
record_mode=record_mode,
match_on=["method", "scheme", "host", "port", "path", "body"],
filter_headers=["authorization", "x-api-key"],
decode_compressed_response=True,
)
return my_vcr.use_cassette(str(cassette_path))
@pytest.fixture
def vcr_cassette():
return make_vcr_cassette
-19
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
-35
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"
-38
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
@@ -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"
-25
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
-37
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
-13
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"
-70
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"
-33
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
-22
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"
-40
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
-30
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"
-27
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
-192
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"
-76
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)}"
-79
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
+396
View File
@@ -0,0 +1,396 @@
"""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", "//list", "//close", "//switch", "//perm",
"//stop", "//progress", "//direct", "//smart", "//shell",
"//help"):
assert cmd in reply, f"Missing {cmd} in help"
# Should NOT list //retry (unimplemented)
assert "//retry" not in reply
# Should list aliases
assert "alias" in reply.lower()
@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 TestList:
@pytest.mark.asyncio
async def test_no_sessions(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//list")
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", "//list")
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", "//list")
assert "" in reply
@pytest.mark.asyncio
async def test_status_alias_still_works(self):
from bot.commands import handle_command
_setup_user()
reply = await handle_command("user_abc123", "//status")
assert "No active sessions" 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
+969
View File
@@ -0,0 +1,969 @@
"""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"]
tags = [e["tag"] for e in body_elements]
# JSON 2.0: no "action" wrapper, no "note" — buttons are direct elements
assert "action" not in tags
assert "note" not in tags
assert "markdown" in tags
assert tags.count("button") == 2
# Buttons carry approve/deny values
buttons = [e for e in body_elements if e["tag"] == "button"]
assert buttons[0]["value"]["action"] == "approve"
assert buttons[0]["value"]["conv_id"] == "c1"
assert buttons[1]["value"]["action"] == "deny"
# ===========================================================================
# 8b. bot/handler.py card callback response
# ===========================================================================
class TestCardCallbackResponse:
"""Test _handle_card_action returns proper P2CardActionTriggerResponse per
docs/feishu/card_callback_communication.md."""
def _make_trigger(self, action: str, conv_id: str, **extra) -> "P2CardActionTrigger":
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
)
value = {"action": action, "conv_id": conv_id, **extra}
act = CallBackAction()
act.value = value
act.tag = "button"
op = CallBackOperator()
op.open_id = "ou_test_user"
data = P2CardActionTriggerData()
data.action = act
data.operator = op
trigger = P2CardActionTrigger()
trigger.event = data
return trigger
def test_approve_returns_toast_and_card(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger("approve", "c1")
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast is not None
assert resp.toast.type == "success"
assert "批准" in resp.toast.content
assert resp.card is not None
assert resp.card.type == "raw"
assert resp.card.data["header"]["template"] == "green"
def test_deny_returns_warning_toast(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger("deny", "c1")
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "warning"
assert "拒绝" in resp.toast.content
assert resp.card.data["header"]["template"] == "red"
def test_missing_value_returns_empty_response(self):
from bot.handler import _handle_card_action
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, P2CardActionTrigger, P2CardActionTriggerData,
)
act = CallBackAction()
act.value = {} # no action/conv_id
data = P2CardActionTriggerData()
data.action = act
trigger = P2CardActionTrigger()
trigger.event = data
resp = _handle_card_action(trigger)
assert resp.toast is None
assert resp.card is None
def test_answer_question_returns_success_toast(self):
from bot.handler import _handle_card_action
trigger = self._make_trigger(
"answer_question", "c1",
question="Which lang?", answer="Python"
)
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
# ===========================================================================
# 8c. AskUserQuestion flow
# ===========================================================================
class TestAskUserQuestion:
def test_build_question_card(self):
from bot.feishu import build_question_card
questions = [
{
"question": "Which language?",
"header": "Language",
"options": [
{"label": "Python", "description": "Great for AI"},
{"label": "TypeScript", "description": "Great for web"},
],
"multiSelect": False,
}
]
card = build_question_card("c1", questions)
assert card["schema"] == "2.0"
assert "提问" in card["header"]["title"]["content"]
elements = card["body"]["elements"]
buttons = [e for e in elements if e["tag"] == "button"]
assert len(buttons) == 2
assert buttons[0]["value"]["action"] == "answer_question"
assert buttons[0]["value"]["question"] == "Which language?"
assert buttons[0]["value"]["answer"] == "Python"
assert buttons[1]["value"]["answer"] == "TypeScript"
@pytest.mark.asyncio
async def test_answer_question_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_question = loop.create_future()
await s.answer_question({"Which language?": "Python"})
assert s._pending_question.done()
assert s._pending_question.result() == {"Which language?": "Python"}
def test_card_callback_answer_question(self):
from bot.handler import _handle_card_action
from lark_oapi.event.callback.model.p2_card_action_trigger import (
CallBackAction, CallBackOperator, P2CardActionTrigger, P2CardActionTriggerData,
)
act = CallBackAction()
act.value = {"action": "answer_question", "conv_id": "c1",
"question": "Which lang?", "answer": "Python"}
act.tag = "button"
op = CallBackOperator()
op.open_id = "ou_test"
data = P2CardActionTriggerData()
data.action = act
data.operator = op
trigger = P2CardActionTrigger()
trigger.event = data
with patch("bot.handler._main_loop", new=MagicMock()):
resp = _handle_card_action(trigger)
assert resp.toast.type == "success"
assert "Python" in resp.toast.content
assert "Python" in resp.card.data["body"]["elements"][0]["content"]
def test_progress_shows_pending_question(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._started_at = time.time()
s._pending_question_data = {
"questions": [{"question": "Pick a color?", "options": [{"label": "Red"}, {"label": "Blue"}]}],
"conv_id": "c1",
}
p = s.get_progress()
assert p.pending_question is not None
assert p.pending_question["questions"][0]["question"] == "Pick a color?"
# ===========================================================================
# 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