feat: add SDK session implementation with approval flow and audit logging
- Implement SDK session with secretary model for tool approval flow - Add audit logging for tool usage and permission decisions - Support Feishu card interactions for approval requests - Add new commands for task interruption and progress checking - Remove old test files and update documentation
This commit is contained in:
parent
ba1b5b76c6
commit
eac90941ef
@ -7,6 +7,7 @@
|
|||||||
| Project architecture, deployment, bot commands | `README.md` |
|
| Project architecture, deployment, bot commands | `README.md` |
|
||||||
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
|
| Claude Agent SDK usage patterns and tested examples | `docs/claude/` |
|
||||||
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` |
|
| SDK migration plan (subprocess → ClaudeSDKClient) | `.claude/plans/toasty-pondering-nova.md` |
|
||||||
|
| SDK session implementation (secretary model) | `agent/sdk_session.py` |
|
||||||
| Feishu card / markdown formatting | `docs/feishu/` |
|
| Feishu card / markdown formatting | `docs/feishu/` |
|
||||||
|
|
||||||
## Claude Agent SDK
|
## Claude Agent SDK
|
||||||
@ -30,7 +31,7 @@ When writing code that uses `claude-agent-sdk`, **first read `docs/claude/`**:
|
|||||||
```
|
```
|
||||||
bot/ Feishu event handling, commands, message sending
|
bot/ Feishu event handling, commands, message sending
|
||||||
orchestrator/ LangChain agent + tools (session management, shell, files, web)
|
orchestrator/ LangChain agent + tools (session management, shell, files, web)
|
||||||
agent/ Claude Code runner, session manager, task runner, scheduler, audit
|
agent/ SDK session (secretary model), session manager, hooks, audit
|
||||||
router/ Multi-host routing (public VPS side)
|
router/ Multi-host routing (public VPS side)
|
||||||
host_client/ Host client (behind NAT, connects to router)
|
host_client/ Host client (behind NAT, connects to router)
|
||||||
shared/ Wire protocol for router ↔ host communication
|
shared/ Wire protocol for router ↔ host communication
|
||||||
|
|||||||
@ -58,6 +58,61 @@ def log_interaction(
|
|||||||
logger.exception("Failed to log audit entry for session %s", conv_id)
|
logger.exception("Failed to log audit entry for session %s", conv_id)
|
||||||
|
|
||||||
|
|
||||||
|
def log_tool_use(
|
||||||
|
session_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: dict,
|
||||||
|
tool_response: Optional[object] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a tool call to the audit JSONL file."""
|
||||||
|
try:
|
||||||
|
_ensure_audit_dir()
|
||||||
|
log_file = AUDIT_DIR / f"{session_id}.jsonl"
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"type": "tool_use",
|
||||||
|
"session_id": session_id,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_input": str(tool_input)[:500],
|
||||||
|
}
|
||||||
|
if tool_response is not None:
|
||||||
|
entry["tool_response"] = str(tool_response)[:500]
|
||||||
|
|
||||||
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to log tool use for session %s", session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def log_permission_decision(
|
||||||
|
conv_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: dict,
|
||||||
|
approved: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Log a permission approval/denial decision."""
|
||||||
|
try:
|
||||||
|
_ensure_audit_dir()
|
||||||
|
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"type": "permission_decision",
|
||||||
|
"conv_id": conv_id,
|
||||||
|
"tool_name": tool_name,
|
||||||
|
"tool_input": str(tool_input)[:300],
|
||||||
|
"approved": approved,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to log permission decision for session %s", conv_id)
|
||||||
|
|
||||||
|
|
||||||
def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
|
def get_audit_log(conv_id: str, limit: int = 100) -> list[dict]:
|
||||||
"""Read the audit log for a session."""
|
"""Read the audit log for a session."""
|
||||||
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
|
log_file = AUDIT_DIR / f"{conv_id}.jsonl"
|
||||||
|
|||||||
196
agent/manager.py
196
agent/manager.py
@ -5,18 +5,20 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field, asdict
|
from dataclasses import dataclass, field, asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from agent.cc_runner import run_claude, DEFAULT_PERMISSION_MODE, VALID_PERMISSION_MODES
|
from agent.sdk_session import (
|
||||||
from agent.audit import log_interaction
|
SDKSession,
|
||||||
|
SessionProgress,
|
||||||
|
DEFAULT_PERMISSION_MODE,
|
||||||
|
VALID_PERMISSION_MODES,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_IDLE_TIMEOUT = 30 * 60
|
DEFAULT_IDLE_TIMEOUT = 30 * 60
|
||||||
DEFAULT_CC_TIMEOUT = 300.0
|
|
||||||
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
|
PERSISTENCE_FILE = Path(__file__).parent.parent / "data" / "sessions.json"
|
||||||
|
|
||||||
|
|
||||||
@ -25,21 +27,27 @@ class Session:
|
|||||||
conv_id: str
|
conv_id: str
|
||||||
cwd: str
|
cwd: str
|
||||||
owner_id: str = ""
|
owner_id: str = ""
|
||||||
cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
||||||
last_activity: float = 0.0
|
last_activity: float = 0.0
|
||||||
started: bool = False
|
|
||||||
idle_timeout: int = DEFAULT_IDLE_TIMEOUT
|
idle_timeout: int = DEFAULT_IDLE_TIMEOUT
|
||||||
cc_timeout: float = DEFAULT_CC_TIMEOUT
|
|
||||||
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE)
|
permission_mode: str = field(default_factory=lambda: DEFAULT_PERMISSION_MODE)
|
||||||
|
chat_id: str | None = None
|
||||||
|
# Runtime only — not serialized
|
||||||
|
sdk_session: SDKSession | None = field(default=None, repr=False)
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = asyncio.get_event_loop().time()
|
self.last_activity = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return asdict(self)
|
d = asdict(self)
|
||||||
|
d.pop("sdk_session", None)
|
||||||
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Session":
|
def from_dict(cls, data: dict) -> "Session":
|
||||||
|
data.pop("sdk_session", None)
|
||||||
|
# Migration: remove old cc_runner fields if present in persisted data
|
||||||
|
for old_key in ("cc_session_id", "started", "cc_timeout"):
|
||||||
|
data.pop(old_key, None)
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +68,9 @@ class SessionManager:
|
|||||||
if self._reaper_task:
|
if self._reaper_task:
|
||||||
self._reaper_task.cancel()
|
self._reaper_task.cancel()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
|
for session in self._sessions.values():
|
||||||
|
if session.sdk_session:
|
||||||
|
await session.sdk_session.close()
|
||||||
self._sessions.clear()
|
self._sessions.clear()
|
||||||
if PERSISTENCE_FILE.exists():
|
if PERSISTENCE_FILE.exists():
|
||||||
PERSISTENCE_FILE.unlink()
|
PERSISTENCE_FILE.unlink()
|
||||||
@ -70,8 +81,8 @@ class SessionManager:
|
|||||||
working_dir: str,
|
working_dir: str,
|
||||||
owner_id: str = "",
|
owner_id: str = "",
|
||||||
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
||||||
cc_timeout: float = DEFAULT_CC_TIMEOUT,
|
|
||||||
permission_mode: str = DEFAULT_PERMISSION_MODE,
|
permission_mode: str = DEFAULT_PERMISSION_MODE,
|
||||||
|
chat_id: str | None = None,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
session = Session(
|
session = Session(
|
||||||
@ -79,103 +90,79 @@ class SessionManager:
|
|||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
idle_timeout=idle_timeout,
|
idle_timeout=idle_timeout,
|
||||||
cc_timeout=cc_timeout,
|
|
||||||
permission_mode=permission_mode,
|
permission_mode=permission_mode,
|
||||||
|
chat_id=chat_id,
|
||||||
)
|
)
|
||||||
self._sessions[conv_id] = session
|
self._sessions[conv_id] = session
|
||||||
self._save()
|
self._save()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Created session %s (owner=...%s) in %s (idle=%ds, cc=%.0fs, perm=%s)",
|
"Created session %s (owner=...%s) in %s (idle=%ds, perm=%s)",
|
||||||
conv_id, owner_id[-8:] if owner_id else "-", working_dir,
|
conv_id, owner_id[-8:] if owner_id else "-", working_dir,
|
||||||
idle_timeout, cc_timeout, permission_mode,
|
idle_timeout, permission_mode,
|
||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def send(self, conv_id: str, message: str, user_id: Optional[str] = None, direct: bool = False) -> str:
|
# --- Secretary model: async send (returns immediately) ---
|
||||||
async with self._lock:
|
|
||||||
session = self._sessions.get(conv_id)
|
async def send_message(
|
||||||
if session is None:
|
self, conv_id: str, message: str,
|
||||||
raise KeyError(f"No session for conv_id={conv_id!r}")
|
user_id: Optional[str] = None, chat_id: Optional[str] = None,
|
||||||
if session.owner_id and user_id and session.owner_id != user_id:
|
) -> str:
|
||||||
raise PermissionError(f"Session {conv_id} belongs to another user")
|
"""Send a message to the session. Returns immediately; result pushed to Feishu on completion."""
|
||||||
|
session = self._get_session(conv_id, user_id)
|
||||||
session.touch()
|
session.touch()
|
||||||
cwd = session.cwd
|
self._ensure_sdk_session(session, chat_id)
|
||||||
cc_session_id = session.cc_session_id
|
return await session.sdk_session.send(message, chat_id)
|
||||||
cc_timeout = session.cc_timeout
|
|
||||||
permission_mode = session.permission_mode
|
|
||||||
first_message = not session.started
|
|
||||||
if first_message:
|
|
||||||
session.started = True
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
if not direct and cc_timeout > 60:
|
async def send_and_wait(
|
||||||
from agent.task_runner import task_runner
|
self, conv_id: str, message: str,
|
||||||
from orchestrator.tools import get_current_chat, set_current_chat, set_current_user
|
user_id: Optional[str] = None, chat_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Send and wait for completion. For LLM agent tool calls that need the result."""
|
||||||
|
session = self._get_session(conv_id, user_id)
|
||||||
|
session.touch()
|
||||||
|
self._ensure_sdk_session(session, chat_id)
|
||||||
|
return await session.sdk_session.send_and_wait(message, chat_id)
|
||||||
|
|
||||||
|
# --- Kept for backward compatibility (used by bot/commands.py _cmd_new) ---
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self, conv_id: str, message: str,
|
||||||
|
user_id: Optional[str] = None, direct: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Backward-compatible send. Maps to send_message (async, secretary model)."""
|
||||||
|
from orchestrator.tools import get_current_chat
|
||||||
chat_id = get_current_chat()
|
chat_id = get_current_chat()
|
||||||
|
return await self.send_message(conv_id, message, user_id=user_id, chat_id=chat_id)
|
||||||
|
|
||||||
async def run_task():
|
# --- Progress, interrupt, approve ---
|
||||||
output = await run_claude(
|
|
||||||
message,
|
|
||||||
cwd=cwd,
|
|
||||||
cc_session_id=cc_session_id,
|
|
||||||
resume=not first_message,
|
|
||||||
timeout=cc_timeout,
|
|
||||||
permission_mode=permission_mode,
|
|
||||||
)
|
|
||||||
log_interaction(
|
|
||||||
conv_id=conv_id,
|
|
||||||
prompt=message,
|
|
||||||
response=output,
|
|
||||||
cwd=cwd,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
|
|
||||||
async def on_task_complete(task) -> None:
|
def get_progress(self, conv_id: str, user_id: Optional[str] = None) -> SessionProgress | None:
|
||||||
if not chat_id or not user_id or not task.result:
|
"""Query session progress. Primary interface for the secretary AI."""
|
||||||
return
|
session = self._sessions.get(conv_id)
|
||||||
set_current_user(user_id)
|
if not session:
|
||||||
set_current_chat(chat_id)
|
return None
|
||||||
from orchestrator.agent import agent
|
if session.owner_id and user_id and session.owner_id != user_id:
|
||||||
follow_up = (
|
return None
|
||||||
f"CC task completed. Output:\n{task.result}\n\n"
|
if session.sdk_session:
|
||||||
f"Original request was: {message}\n\n"
|
return session.sdk_session.get_progress()
|
||||||
"If the user asked you to send a file, use send_file now. "
|
return SessionProgress()
|
||||||
"Otherwise just acknowledge completion."
|
|
||||||
)
|
|
||||||
reply = await agent.run(user_id, follow_up)
|
|
||||||
if reply:
|
|
||||||
from bot.feishu import send_text
|
|
||||||
await send_text(chat_id, "chat_id", reply)
|
|
||||||
|
|
||||||
task_id = await task_runner.submit(
|
async def interrupt(self, conv_id: str, user_id: Optional[str] = None) -> bool:
|
||||||
run_task(),
|
"""Interrupt the currently running task in a session."""
|
||||||
description=f"CC session {conv_id}: {message[:50]}",
|
session = self._get_session(conv_id, user_id)
|
||||||
notify_chat_id=chat_id,
|
if session.sdk_session:
|
||||||
user_id=user_id,
|
await session.sdk_session.interrupt()
|
||||||
on_complete=on_task_complete,
|
return True
|
||||||
)
|
return False
|
||||||
return f"⏳ Task #{task_id} started (timeout: {int(cc_timeout)}s). I'll notify you when it's done."
|
|
||||||
|
|
||||||
output = await run_claude(
|
async def approve(self, conv_id: str, approved: bool) -> None:
|
||||||
message,
|
"""Resolve a pending tool approval for a session."""
|
||||||
cwd=cwd,
|
session = self._sessions.get(conv_id)
|
||||||
cc_session_id=cc_session_id,
|
if session and session.sdk_session:
|
||||||
resume=not first_message,
|
await session.sdk_session.approve(approved)
|
||||||
timeout=cc_timeout,
|
|
||||||
permission_mode=permission_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
log_interaction(
|
# --- Close, list, permission ---
|
||||||
conv_id=conv_id,
|
|
||||||
prompt=message,
|
|
||||||
response=output,
|
|
||||||
cwd=cwd,
|
|
||||||
user_id=user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
|
async def close(self, conv_id: str, user_id: Optional[str] = None) -> bool:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@ -184,6 +171,8 @@ class SessionManager:
|
|||||||
return False
|
return False
|
||||||
if session.owner_id and user_id and session.owner_id != user_id:
|
if session.owner_id and user_id and session.owner_id != user_id:
|
||||||
raise PermissionError(f"Session {conv_id} belongs to another user")
|
raise PermissionError(f"Session {conv_id} belongs to another user")
|
||||||
|
if session.sdk_session:
|
||||||
|
await session.sdk_session.close()
|
||||||
del self._sessions[conv_id]
|
del self._sessions[conv_id]
|
||||||
self._save()
|
self._save()
|
||||||
logger.info("Closed session %s", conv_id)
|
logger.info("Closed session %s", conv_id)
|
||||||
@ -198,10 +187,8 @@ class SessionManager:
|
|||||||
"conv_id": s.conv_id,
|
"conv_id": s.conv_id,
|
||||||
"cwd": s.cwd,
|
"cwd": s.cwd,
|
||||||
"owner_id": s.owner_id[-8:] if s.owner_id else None,
|
"owner_id": s.owner_id[-8:] if s.owner_id else None,
|
||||||
"cc_session_id": s.cc_session_id,
|
"busy": s.sdk_session._busy if s.sdk_session else False,
|
||||||
"started": s.started,
|
|
||||||
"idle_timeout": s.idle_timeout,
|
"idle_timeout": s.idle_timeout,
|
||||||
"cc_timeout": s.cc_timeout,
|
|
||||||
"permission_mode": s.permission_mode,
|
"permission_mode": s.permission_mode,
|
||||||
}
|
}
|
||||||
for s in sessions
|
for s in sessions
|
||||||
@ -217,9 +204,31 @@ class SessionManager:
|
|||||||
if mode not in VALID_PERMISSION_MODES:
|
if mode not in VALID_PERMISSION_MODES:
|
||||||
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
|
raise ValueError(f"Invalid permission mode {mode!r}. Valid: {VALID_PERMISSION_MODES}")
|
||||||
session.permission_mode = mode
|
session.permission_mode = mode
|
||||||
|
if session.sdk_session:
|
||||||
|
asyncio.create_task(session.sdk_session.set_permission_mode(mode))
|
||||||
self._save()
|
self._save()
|
||||||
logger.info("Set permission_mode=%s for session %s", mode, conv_id)
|
logger.info("Set permission_mode=%s for session %s", mode, conv_id)
|
||||||
|
|
||||||
|
# --- Internal ---
|
||||||
|
|
||||||
|
def _get_session(self, conv_id: str, user_id: Optional[str] = None) -> Session:
|
||||||
|
session = self._sessions.get(conv_id)
|
||||||
|
if session is None:
|
||||||
|
raise KeyError(f"No session for conv_id={conv_id!r}")
|
||||||
|
if session.owner_id and user_id and session.owner_id != user_id:
|
||||||
|
raise PermissionError(f"Session {conv_id} belongs to another user")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _ensure_sdk_session(self, session: Session, chat_id: str | None = None) -> None:
|
||||||
|
if session.sdk_session is None:
|
||||||
|
session.sdk_session = SDKSession(
|
||||||
|
conv_id=session.conv_id,
|
||||||
|
cwd=session.cwd,
|
||||||
|
owner_id=session.owner_id,
|
||||||
|
permission_mode=session.permission_mode,
|
||||||
|
chat_id=chat_id or session.chat_id,
|
||||||
|
)
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
data = {cid: s.to_dict() for cid, s in self._sessions.items()}
|
data = {cid: s.to_dict() for cid, s in self._sessions.items()}
|
||||||
@ -255,6 +264,9 @@ class SessionManager:
|
|||||||
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
|
if s.last_activity > 0 and (now - s.last_activity) > s.idle_timeout:
|
||||||
to_close.append(cid)
|
to_close.append(cid)
|
||||||
for cid in to_close:
|
for cid in to_close:
|
||||||
|
session = self._sessions[cid]
|
||||||
|
if session.sdk_session:
|
||||||
|
await session.sdk_session.close()
|
||||||
del self._sessions[cid]
|
del self._sessions[cid]
|
||||||
logger.info("Reaped idle session %s", cid)
|
logger.info("Reaped idle session %s", cid)
|
||||||
if to_close:
|
if to_close:
|
||||||
|
|||||||
72
agent/sdk_hooks.py
Normal file
72
agent/sdk_hooks.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""SDK hooks for audit logging and dangerous command blocking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from claude_agent_sdk import HookContext, HookInput, HookJSONOutput, HookMatcher
|
||||||
|
|
||||||
|
BLOCKED_PATTERNS = [
|
||||||
|
r"\brm\s+-rf\s+/",
|
||||||
|
r"\brm\s+-rf\s+~",
|
||||||
|
r"\bformat\s+",
|
||||||
|
r"\bmkfs\b",
|
||||||
|
r"\bshutdown\b",
|
||||||
|
r"\breboot\b",
|
||||||
|
r"\bdd\s+if=",
|
||||||
|
r":\(\)\{:\|:&\};:",
|
||||||
|
r"\bchmod\s+777\s+/",
|
||||||
|
r"\bchown\s+.*\s+/",
|
||||||
|
r"\bsudo\s+rm\b",
|
||||||
|
r"\bsudo\s+chmod\b",
|
||||||
|
r"\bsudo\s+chown\b",
|
||||||
|
r"\bsudo\s+dd\b",
|
||||||
|
r"\bkill\s+-9\s+1\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def audit_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""PostToolUse hook — log tool calls to audit JSONL."""
|
||||||
|
from agent.audit import log_tool_use
|
||||||
|
|
||||||
|
log_tool_use(
|
||||||
|
session_id=input_data.get("session_id", ""),
|
||||||
|
tool_name=input_data.get("tool_name", ""),
|
||||||
|
tool_input=input_data.get("tool_input", {}),
|
||||||
|
tool_response=input_data.get("tool_response"),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def deny_dangerous_hook(
|
||||||
|
input_data: HookInput, tool_use_id: str | None, context: HookContext
|
||||||
|
) -> HookJSONOutput:
|
||||||
|
"""PreToolUse hook — block dangerous Bash commands."""
|
||||||
|
if input_data.get("tool_name") != "Bash":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
command = input_data.get("tool_input", {}).get("command", "")
|
||||||
|
for pattern in BLOCKED_PATTERNS:
|
||||||
|
if re.search(pattern, command, re.IGNORECASE):
|
||||||
|
return {
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "deny",
|
||||||
|
"permissionDecisionReason": f"Blocked by policy: matches {pattern}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def build_hooks(conv_id: str) -> dict[str, list[HookMatcher]]:
|
||||||
|
"""Build hooks configuration for a session."""
|
||||||
|
return {
|
||||||
|
"PostToolUse": [
|
||||||
|
HookMatcher(matcher="Bash|Edit|Write|MultiEdit", hooks=[audit_hook]),
|
||||||
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
HookMatcher(matcher="Bash", hooks=[deny_dangerous_hook]),
|
||||||
|
],
|
||||||
|
}
|
||||||
355
agent/sdk_session.py
Normal file
355
agent/sdk_session.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
"""SDK-based Claude Code session — secretary model.
|
||||||
|
|
||||||
|
Messages are buffered in memory, not pushed to Feishu in real-time.
|
||||||
|
Only key events (completion, error, approval) trigger notifications.
|
||||||
|
The secretary AI queries get_progress() to answer user questions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
AssistantMessage,
|
||||||
|
ClaudeAgentOptions,
|
||||||
|
ClaudeSDKClient,
|
||||||
|
PermissionMode,
|
||||||
|
PermissionResult,
|
||||||
|
PermissionResultAllow,
|
||||||
|
PermissionResultDeny,
|
||||||
|
ResultMessage,
|
||||||
|
SystemMessage,
|
||||||
|
TextBlock,
|
||||||
|
ToolPermissionContext,
|
||||||
|
ToolUseBlock,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VALID_PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]
|
||||||
|
DEFAULT_PERMISSION_MODE = "default"
|
||||||
|
APPROVAL_TIMEOUT = 120 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionProgress:
|
||||||
|
"""Session progress snapshot for the secretary AI to inspect."""
|
||||||
|
|
||||||
|
busy: bool = False
|
||||||
|
current_prompt: str = ""
|
||||||
|
started_at: float = 0.0
|
||||||
|
elapsed_seconds: float = 0.0
|
||||||
|
text_messages: list[str] = field(default_factory=list)
|
||||||
|
tool_calls: list[str] = field(default_factory=list)
|
||||||
|
last_result: str = ""
|
||||||
|
error: str = ""
|
||||||
|
pending_approval: str = "" # non-empty → waiting for approval, value is tool description
|
||||||
|
|
||||||
|
|
||||||
|
class SDKSession:
|
||||||
|
"""One session = one long-lived ClaudeSDKClient + background message buffer loop.
|
||||||
|
|
||||||
|
Secretary model design:
|
||||||
|
- _message_loop buffers all messages to memory, does NOT push to Feishu
|
||||||
|
- Only pushes on key events: completion (ResultMessage), error, approval needed
|
||||||
|
- get_progress() returns a snapshot for the secretary AI to inspect
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_BUFFER_TEXTS = 20
|
||||||
|
MAX_BUFFER_TOOLS = 50
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
conv_id: str,
|
||||||
|
cwd: str,
|
||||||
|
owner_id: str,
|
||||||
|
permission_mode: str = DEFAULT_PERMISSION_MODE,
|
||||||
|
chat_id: str | None = None,
|
||||||
|
):
|
||||||
|
self.conv_id = conv_id
|
||||||
|
self.cwd = cwd
|
||||||
|
self.owner_id = owner_id
|
||||||
|
self.permission_mode = permission_mode
|
||||||
|
self.chat_id = chat_id
|
||||||
|
|
||||||
|
self.client: ClaudeSDKClient | None = None
|
||||||
|
self.session_id: str | None = None
|
||||||
|
|
||||||
|
# Message buffers
|
||||||
|
self._text_buffer: list[str] = []
|
||||||
|
self._tool_buffer: list[str] = []
|
||||||
|
self._last_result: str = ""
|
||||||
|
self._error: str = ""
|
||||||
|
self._current_prompt: str = ""
|
||||||
|
self._started_at: float = 0.0
|
||||||
|
|
||||||
|
# Task state
|
||||||
|
self._message_loop_task: asyncio.Task | None = None
|
||||||
|
self._busy = False
|
||||||
|
self._busy_event = asyncio.Event()
|
||||||
|
self._busy_event.set() # initially idle
|
||||||
|
|
||||||
|
# Approval mechanism
|
||||||
|
self._pending_approval: asyncio.Future | None = None
|
||||||
|
self._pending_approval_desc: str = ""
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Create and connect the ClaudeSDKClient, start the message loop."""
|
||||||
|
from agent.sdk_hooks import build_hooks
|
||||||
|
|
||||||
|
env = self._build_env()
|
||||||
|
hooks = build_hooks(self.conv_id)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
cwd=self.cwd,
|
||||||
|
permission_mode=self.permission_mode,
|
||||||
|
allowed_tools=[
|
||||||
|
"Read", "Glob", "Grep", "Bash", "Edit", "Write",
|
||||||
|
"MultiEdit", "WebFetch", "WebSearch",
|
||||||
|
],
|
||||||
|
can_use_tool=self._permission_callback,
|
||||||
|
hooks=hooks,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self.client = ClaudeSDKClient(options)
|
||||||
|
await self.client.connect()
|
||||||
|
|
||||||
|
self._message_loop_task = asyncio.create_task(
|
||||||
|
self._message_loop(), name=f"sdk-loop-{self.conv_id}"
|
||||||
|
)
|
||||||
|
logger.info("SDKSession %s started in %s", self.conv_id, self.cwd)
|
||||||
|
|
||||||
|
async def send(self, prompt: str, chat_id: str | None = None) -> str:
|
||||||
|
"""Send a message. Returns immediately; execution happens in background."""
|
||||||
|
if not self.client:
|
||||||
|
await self.start()
|
||||||
|
|
||||||
|
if chat_id:
|
||||||
|
self.chat_id = chat_id
|
||||||
|
|
||||||
|
# If busy, interrupt the current task first
|
||||||
|
if self._busy:
|
||||||
|
await self.interrupt()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._busy_event.wait(), timeout=10)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._busy = True
|
||||||
|
self._busy_event.clear()
|
||||||
|
self._current_prompt = prompt
|
||||||
|
self._started_at = time.time()
|
||||||
|
self._last_result = ""
|
||||||
|
self._error = ""
|
||||||
|
self._text_buffer.clear()
|
||||||
|
self._tool_buffer.clear()
|
||||||
|
|
||||||
|
await self.client.query(prompt)
|
||||||
|
return "⏳ 已开始执行"
|
||||||
|
|
||||||
|
async def send_and_wait(self, prompt: str, chat_id: str | None = None) -> str:
|
||||||
|
"""Send and wait for completion. For LLM agent tool calls."""
|
||||||
|
await self.send(prompt, chat_id)
|
||||||
|
await self._busy_event.wait()
|
||||||
|
return self._last_result or self._error or "(no output)"
|
||||||
|
|
||||||
|
def get_progress(self) -> SessionProgress:
|
||||||
|
"""Return a progress snapshot. Primary query interface for the secretary AI."""
|
||||||
|
return SessionProgress(
|
||||||
|
busy=self._busy,
|
||||||
|
current_prompt=self._current_prompt,
|
||||||
|
started_at=self._started_at,
|
||||||
|
elapsed_seconds=time.time() - self._started_at if self._busy else 0,
|
||||||
|
text_messages=list(self._text_buffer[-5:]),
|
||||||
|
tool_calls=list(self._tool_buffer[-10:]),
|
||||||
|
last_result=self._last_result[:1000],
|
||||||
|
error=self._error,
|
||||||
|
pending_approval=self._pending_approval_desc,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def interrupt(self) -> None:
|
||||||
|
"""Interrupt the currently running task."""
|
||||||
|
if self.client and self._busy:
|
||||||
|
await self.client.interrupt()
|
||||||
|
logger.info("SDKSession %s interrupted", self.conv_id)
|
||||||
|
|
||||||
|
async def set_permission_mode(self, mode: PermissionMode) -> None:
|
||||||
|
"""Dynamically change the permission mode."""
|
||||||
|
if self.client:
|
||||||
|
await self.client.set_permission_mode(mode)
|
||||||
|
self.permission_mode = mode
|
||||||
|
logger.info("SDKSession %s permission_mode → %s", self.conv_id, mode)
|
||||||
|
|
||||||
|
async def approve(self, approved: bool) -> None:
|
||||||
|
"""Resolve a pending tool approval."""
|
||||||
|
if self._pending_approval and not self._pending_approval.done():
|
||||||
|
self._pending_approval.set_result(approved)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Disconnect and clean up."""
|
||||||
|
if self._message_loop_task and not self._message_loop_task.done():
|
||||||
|
self._message_loop_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._message_loop_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self.client:
|
||||||
|
await self.client.disconnect()
|
||||||
|
self.client = None
|
||||||
|
logger.info("SDKSession %s closed", self.conv_id)
|
||||||
|
|
||||||
|
# --- Internal ---
|
||||||
|
|
||||||
|
async def _message_loop(self) -> None:
|
||||||
|
"""Background message consumption loop. Buffers messages, notifies on key events."""
|
||||||
|
from agent.audit import log_interaction
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in self.client.receive_messages():
|
||||||
|
if isinstance(msg, SystemMessage) and msg.subtype == "init":
|
||||||
|
self.session_id = msg.data.get("session_id")
|
||||||
|
|
||||||
|
elif isinstance(msg, AssistantMessage):
|
||||||
|
for block in msg.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
self._text_buffer.append(block.text)
|
||||||
|
if len(self._text_buffer) > self.MAX_BUFFER_TEXTS:
|
||||||
|
self._text_buffer.pop(0)
|
||||||
|
elif isinstance(block, ToolUseBlock):
|
||||||
|
summary = f"{block.name}({self._summarize_input(block.input)})"
|
||||||
|
self._tool_buffer.append(summary)
|
||||||
|
if len(self._tool_buffer) > self.MAX_BUFFER_TOOLS:
|
||||||
|
self._tool_buffer.pop(0)
|
||||||
|
|
||||||
|
elif isinstance(msg, ResultMessage):
|
||||||
|
self._last_result = msg.result or ""
|
||||||
|
self._busy = False
|
||||||
|
self._busy_event.set()
|
||||||
|
|
||||||
|
# Key event: task completed → notify Feishu
|
||||||
|
if self.chat_id:
|
||||||
|
await self._notify_completion()
|
||||||
|
|
||||||
|
log_interaction(
|
||||||
|
conv_id=self.conv_id,
|
||||||
|
prompt=self._current_prompt,
|
||||||
|
response=self._last_result[:2000],
|
||||||
|
cwd=self.cwd,
|
||||||
|
user_id=self.owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug("Message loop cancelled for %s", self.conv_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Message loop error for %s", self.conv_id)
|
||||||
|
self._error = str(exc)
|
||||||
|
self._busy = False
|
||||||
|
self._busy_event.set()
|
||||||
|
if self.chat_id:
|
||||||
|
await self._notify_error(str(exc))
|
||||||
|
|
||||||
|
async def _notify_completion(self) -> None:
|
||||||
|
from bot.feishu import send_markdown
|
||||||
|
|
||||||
|
result_preview = self._last_result[:800]
|
||||||
|
if len(self._last_result) > 800:
|
||||||
|
result_preview += "\n...[truncated]"
|
||||||
|
elapsed = int(time.time() - self._started_at)
|
||||||
|
tools_used = len(self._tool_buffer)
|
||||||
|
msg = f"✅ **任务完成** ({elapsed}s, {tools_used} tool calls)\n\n{result_preview}"
|
||||||
|
try:
|
||||||
|
await send_markdown(self.chat_id, "chat_id", msg)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to notify completion")
|
||||||
|
|
||||||
|
async def _notify_error(self, error: str) -> None:
|
||||||
|
from bot.feishu import send_markdown
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_markdown(
|
||||||
|
self.chat_id, "chat_id",
|
||||||
|
f"❌ **任务出错**\n\n```\n{error[:500]}\n```",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to notify error")
|
||||||
|
|
||||||
|
async def _permission_callback(
|
||||||
|
self, tool_name: str, input_data: dict, context: ToolPermissionContext
|
||||||
|
) -> PermissionResult:
|
||||||
|
"""can_use_tool — send approval card to Feishu, wait for card callback."""
|
||||||
|
# Auto-allow read-only tools
|
||||||
|
if tool_name in ("Read", "Glob", "Grep", "WebSearch", "WebFetch"):
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
if not self.chat_id:
|
||||||
|
return PermissionResultAllow()
|
||||||
|
|
||||||
|
# Send approval card
|
||||||
|
from bot.feishu import send_card, build_approval_card
|
||||||
|
|
||||||
|
summary = self._format_tool_summary(tool_name, input_data)
|
||||||
|
self._pending_approval_desc = f"{tool_name}: {summary}"
|
||||||
|
|
||||||
|
card = build_approval_card(
|
||||||
|
conv_id=self.conv_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
summary=summary,
|
||||||
|
timeout=APPROVAL_TIMEOUT,
|
||||||
|
)
|
||||||
|
await send_card(self.chat_id, "chat_id", card)
|
||||||
|
|
||||||
|
# Wait for card callback or text reply y/n
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._pending_approval = loop.create_future()
|
||||||
|
try:
|
||||||
|
approved = await asyncio.wait_for(
|
||||||
|
self._pending_approval, timeout=APPROVAL_TIMEOUT
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
approved = False
|
||||||
|
from bot.feishu import send_markdown
|
||||||
|
|
||||||
|
await send_markdown(self.chat_id, "chat_id", "⏰ 审批超时,已自动拒绝。")
|
||||||
|
finally:
|
||||||
|
self._pending_approval_desc = ""
|
||||||
|
|
||||||
|
from agent.audit import log_permission_decision
|
||||||
|
|
||||||
|
log_permission_decision(
|
||||||
|
conv_id=self.conv_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=input_data,
|
||||||
|
approved=approved,
|
||||||
|
)
|
||||||
|
if approved:
|
||||||
|
return PermissionResultAllow()
|
||||||
|
return PermissionResultDeny(message="用户拒绝了此操作")
|
||||||
|
|
||||||
|
def _format_tool_summary(self, tool_name: str, input_data: dict) -> str:
|
||||||
|
if tool_name == "Bash":
|
||||||
|
return f"`{input_data.get('command', '')[:200]}`"
|
||||||
|
if tool_name in ("Edit", "Write", "MultiEdit"):
|
||||||
|
return f"file: `{input_data.get('file_path', input_data.get('path', ''))}`"
|
||||||
|
return str(input_data)[:200]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summarize_input(input_data: dict) -> str:
|
||||||
|
if "command" in input_data:
|
||||||
|
return input_data["command"][:80]
|
||||||
|
if "file_path" in input_data:
|
||||||
|
return input_data["file_path"]
|
||||||
|
return str(input_data)[:60]
|
||||||
|
|
||||||
|
def _build_env(self) -> dict[str, str]:
|
||||||
|
import os
|
||||||
|
|
||||||
|
env = {}
|
||||||
|
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||||
|
val = os.environ.get(key, "")
|
||||||
|
if val:
|
||||||
|
env[key] = val
|
||||||
|
return env
|
||||||
@ -12,7 +12,7 @@ from typing import Optional, Tuple
|
|||||||
from agent.manager import manager
|
from agent.manager import manager
|
||||||
from agent.scheduler import scheduler
|
from agent.scheduler import scheduler
|
||||||
from agent.task_runner import task_runner
|
from agent.task_runner import task_runner
|
||||||
from agent.cc_runner import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
|
from agent.sdk_session import VALID_PERMISSION_MODES, DEFAULT_PERMISSION_MODE
|
||||||
from orchestrator.agent import agent
|
from orchestrator.agent import agent
|
||||||
from orchestrator.tools import set_current_user, get_current_chat
|
from orchestrator.tools import set_current_user, get_current_chat
|
||||||
|
|
||||||
@ -24,12 +24,14 @@ _PERM_ALIASES: dict[str, str] = {
|
|||||||
"default": "default",
|
"default": "default",
|
||||||
"edit": "acceptEdits",
|
"edit": "acceptEdits",
|
||||||
"plan": "plan",
|
"plan": "plan",
|
||||||
|
"auto": "dontAsk",
|
||||||
}
|
}
|
||||||
_PERM_LABELS: dict[str, str] = {
|
_PERM_LABELS: dict[str, str] = {
|
||||||
"default": "default",
|
"default": "default",
|
||||||
"bypassPermissions": "bypass",
|
"bypassPermissions": "bypass",
|
||||||
"acceptEdits": "edit",
|
"acceptEdits": "edit",
|
||||||
"plan": "plan",
|
"plan": "plan",
|
||||||
|
"dontAsk": "auto",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -105,6 +107,10 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
|
|||||||
return await _cmd_remind(args)
|
return await _cmd_remind(args)
|
||||||
elif cmd == P+"perm":
|
elif cmd == P+"perm":
|
||||||
return await _cmd_perm(user_id, args)
|
return await _cmd_perm(user_id, args)
|
||||||
|
elif cmd in (P+"stop", P+"interrupt"):
|
||||||
|
return await _cmd_stop(user_id)
|
||||||
|
elif cmd in (P+"progress", P+"prog", P+"p"):
|
||||||
|
return await _cmd_progress(user_id)
|
||||||
elif cmd in (P+"nodes", P+"node"):
|
elif cmd in (P+"nodes", P+"node"):
|
||||||
return await _cmd_nodes(user_id, args)
|
return await _cmd_nodes(user_id, args)
|
||||||
else:
|
else:
|
||||||
@ -114,26 +120,25 @@ async def handle_command(user_id: str, text: str) -> Optional[str]:
|
|||||||
async def _cmd_new(user_id: str, args: str) -> str:
|
async def _cmd_new(user_id: str, args: str) -> str:
|
||||||
"""Create a new session."""
|
"""Create a new session."""
|
||||||
if not args:
|
if not args:
|
||||||
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--perm MODE]\nModes: default, edit, plan, bypass"
|
return "Usage: /new <project_dir> [initial_message] [--perm MODE]\nModes: default, edit, plan, bypass, auto"
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("working_dir", nargs="?", help="Project directory")
|
parser.add_argument("working_dir", nargs="?", help="Project directory")
|
||||||
parser.add_argument("rest", nargs="*", help="Initial message")
|
parser.add_argument("rest", nargs="*", help="Initial message")
|
||||||
parser.add_argument("--timeout", type=int, default=None, help="CC timeout in seconds")
|
|
||||||
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
|
parser.add_argument("--idle", type=int, default=None, help="Idle timeout in seconds")
|
||||||
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass")
|
parser.add_argument("--perm", default=None, help="Permission mode: default, edit, plan, bypass, auto")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = parser.parse_args(args.split())
|
parsed = parser.parse_args(args.split())
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
return "Usage: /new <project_dir> [initial_message] [--timeout N] [--idle N] [--perm MODE]"
|
return "Usage: /new <project_dir> [initial_message] [--idle N] [--perm MODE]"
|
||||||
|
|
||||||
if not parsed.working_dir:
|
if not parsed.working_dir:
|
||||||
return "Error: project_dir is required"
|
return "Error: project_dir is required"
|
||||||
|
|
||||||
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
|
permission_mode = _resolve_perm(parsed.perm) if parsed.perm else DEFAULT_PERMISSION_MODE
|
||||||
if permission_mode is None:
|
if permission_mode is None:
|
||||||
return f"Invalid --perm. Valid modes: default, edit, plan, bypass"
|
return f"Invalid --perm. Valid modes: default, edit, plan, bypass, auto"
|
||||||
|
|
||||||
working_dir = parsed.working_dir
|
working_dir = parsed.working_dir
|
||||||
initial_msg = " ".join(parsed.rest) if parsed.rest else None
|
initial_msg = " ".join(parsed.rest) if parsed.rest else None
|
||||||
@ -147,13 +152,14 @@ async def _cmd_new(user_id: str, args: str) -> str:
|
|||||||
|
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
conv_id = str(_uuid.uuid4())[:8]
|
conv_id = str(_uuid.uuid4())[:8]
|
||||||
|
chat_id = get_current_chat()
|
||||||
await manager.create(
|
await manager.create(
|
||||||
conv_id,
|
conv_id,
|
||||||
str(resolved),
|
str(resolved),
|
||||||
owner_id=user_id,
|
owner_id=user_id,
|
||||||
idle_timeout=parsed.idle or 1800,
|
idle_timeout=parsed.idle or 1800,
|
||||||
cc_timeout=float(parsed.timeout or 300),
|
|
||||||
permission_mode=permission_mode,
|
permission_mode=permission_mode,
|
||||||
|
chat_id=chat_id,
|
||||||
)
|
)
|
||||||
agent._active_conv[user_id] = conv_id
|
agent._active_conv[user_id] = conv_id
|
||||||
|
|
||||||
@ -161,7 +167,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
|
|||||||
if initial_msg:
|
if initial_msg:
|
||||||
response = await manager.send(conv_id, initial_msg, user_id=user_id)
|
response = await manager.send(conv_id, initial_msg, user_id=user_id)
|
||||||
|
|
||||||
chat_id = get_current_chat()
|
|
||||||
if chat_id:
|
if chat_id:
|
||||||
from bot.feishu import send_card, send_text, build_sessions_card
|
from bot.feishu import send_card, send_text, build_sessions_card
|
||||||
sessions = manager.list_sessions(user_id=user_id)
|
sessions = manager.list_sessions(user_id=user_id)
|
||||||
@ -174,8 +179,6 @@ async def _cmd_new(user_id: str, args: str) -> str:
|
|||||||
|
|
||||||
perm_label = _perm_label(permission_mode)
|
perm_label = _perm_label(permission_mode)
|
||||||
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
|
reply = f"✓ Created session `{conv_id}` in `{resolved}` [{perm_label}]"
|
||||||
if parsed.timeout:
|
|
||||||
reply += f" (timeout: {parsed.timeout}s)"
|
|
||||||
if initial_msg and response:
|
if initial_msg and response:
|
||||||
reply += f"\n\n{response}"
|
reply += f"\n\n{response}"
|
||||||
return reply
|
return reply
|
||||||
@ -289,11 +292,12 @@ async def _cmd_perm(user_id: str, args: str) -> str:
|
|||||||
if not parts:
|
if not parts:
|
||||||
return (
|
return (
|
||||||
"Usage: /perm <mode> [conv_id]\n"
|
"Usage: /perm <mode> [conv_id]\n"
|
||||||
"Modes: default, edit, plan, bypass\n"
|
"Modes: default, edit, plan, bypass, auto\n"
|
||||||
" default — default mode\n"
|
" default — default mode\n"
|
||||||
" edit — auto-accept file edits, confirm shell commands\n"
|
" edit — auto-accept file edits, confirm shell commands\n"
|
||||||
" plan — plan only, no writes\n"
|
" plan — plan only, no writes\n"
|
||||||
" bypass — skip all permission checks"
|
" bypass — skip all permission checks\n"
|
||||||
|
" auto — allow all tools, don't ask"
|
||||||
)
|
)
|
||||||
|
|
||||||
alias = parts[0]
|
alias = parts[0]
|
||||||
@ -403,6 +407,38 @@ async def _cmd_remind(args: str) -> str:
|
|||||||
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
|
return f"⏰ Reminder #{job_id} set for {value}{unit} from now"
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_stop(user_id: str) -> str:
|
||||||
|
"""Interrupt the current task in the active session."""
|
||||||
|
conv_id = agent.get_active_conv(user_id)
|
||||||
|
if not conv_id:
|
||||||
|
return "No active session."
|
||||||
|
try:
|
||||||
|
success = await manager.interrupt(conv_id, user_id)
|
||||||
|
return "✓ Interrupted" if success else "No active task."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_progress(user_id: str) -> str:
|
||||||
|
"""Show progress of the active session."""
|
||||||
|
conv_id = agent.get_active_conv(user_id)
|
||||||
|
if not conv_id:
|
||||||
|
return "No active session."
|
||||||
|
progress = manager.get_progress(conv_id, user_id)
|
||||||
|
if not progress:
|
||||||
|
return "Session not found."
|
||||||
|
if not progress.busy:
|
||||||
|
if progress.last_result:
|
||||||
|
return f"✅ 已完成\n\n{progress.last_result[:500]}"
|
||||||
|
return "空闲中,无正在执行的任务。"
|
||||||
|
elapsed = int(progress.elapsed_seconds)
|
||||||
|
tools = ", ".join(progress.tool_calls[-3:]) if progress.tool_calls else "none"
|
||||||
|
pending = ""
|
||||||
|
if progress.pending_approval:
|
||||||
|
pending = f"\n⚠️ 等待审批: {progress.pending_approval}"
|
||||||
|
return f"⏳ 执行中 ({elapsed}s)\n最近工具: {tools}{pending}"
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_nodes(user_id: str, args: str) -> str:
|
async def _cmd_nodes(user_id: str, args: str) -> str:
|
||||||
"""List nodes or switch active node."""
|
"""List nodes or switch active node."""
|
||||||
from config import ROUTER_MODE
|
from config import ROUTER_MODE
|
||||||
@ -440,11 +476,13 @@ def _cmd_help() -> str:
|
|||||||
"""Show help."""
|
"""Show help."""
|
||||||
from config import COMMAND_PREFIX as P
|
from config import COMMAND_PREFIX as P
|
||||||
return f"""**Commands:** (prefix: `{P}`)
|
return f"""**Commands:** (prefix: `{P}`)
|
||||||
{P}new <dir> [msg] [--timeout N] [--idle N] [--perm MODE] - Create session
|
{P}new <dir> [msg] [--idle N] [--perm MODE] - Create session
|
||||||
{P}status - Show sessions and current mode
|
{P}status - Show sessions and current mode
|
||||||
{P}close [n] - Close session (active or by number)
|
{P}close [n] - Close session (active or by number)
|
||||||
{P}switch <n> - Switch to session by number
|
{P}switch <n> - Switch to session by number
|
||||||
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass)
|
{P}perm <mode> [conv_id] - Set permission mode (default/edit/plan/bypass/auto)
|
||||||
|
{P}stop - Interrupt the current task
|
||||||
|
{P}progress - Show task progress
|
||||||
{P}direct - Direct mode: messages → Claude Code (no LLM overhead)
|
{P}direct - Direct mode: messages → Claude Code (no LLM overhead)
|
||||||
{P}smart - Smart mode: messages → LLM routing (default)
|
{P}smart - Smart mode: messages → LLM routing (default)
|
||||||
{P}shell <cmd> - Run shell command (bypasses LLM)
|
{P}shell <cmd> - Run shell command (bypasses LLM)
|
||||||
@ -462,4 +500,5 @@ def _cmd_help() -> str:
|
|||||||
plan — 只规划、不执行任何写操作
|
plan — 只规划、不执行任何写操作
|
||||||
适合:先预览 CC 的操作计划再决定是否执行
|
适合:先预览 CC 的操作计划再决定是否执行
|
||||||
bypass — 跳过所有权限确认,CC 自动执行一切操作
|
bypass — 跳过所有权限确认,CC 自动执行一切操作
|
||||||
适合:受信任的沙盒环境、自动化任务"""
|
适合:受信任的沙盒环境、自动化任务
|
||||||
|
auto — 允许所有工具,不询问(等效 bypass + dontAsk)"""
|
||||||
@ -128,8 +128,8 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
|
|||||||
lines = []
|
lines = []
|
||||||
for i, s in enumerate(sessions, 1):
|
for i, s in enumerate(sessions, 1):
|
||||||
marker = "→" if s["conv_id"] == active_conv_id else " "
|
marker = "→" if s["conv_id"] == active_conv_id else " "
|
||||||
started = "🟢" if s["started"] else "🟡"
|
status = "🔵" if s.get("busy") else "⚪"
|
||||||
lines.append(f"{marker} {i}. {started} `{s['conv_id']}` — `{s['cwd']}`")
|
lines.append(f"{marker} {i}. {status} `{s['conv_id']}` — `{s['cwd']}`")
|
||||||
sessions_md = "\n".join(lines)
|
sessions_md = "\n".join(lines)
|
||||||
else:
|
else:
|
||||||
sessions_md = "_No active sessions_"
|
sessions_md = "_No active sessions_"
|
||||||
@ -148,6 +148,48 @@ def build_sessions_card(sessions: list[dict], active_conv_id: str | None, mode:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_approval_card(conv_id: str, tool_name: str, summary: str, timeout: int = 120) -> dict:
|
||||||
|
"""Build an approval card for a tool call (schema 2.0, with approve/deny buttons)."""
|
||||||
|
return {
|
||||||
|
"schema": "2.0",
|
||||||
|
"header": {
|
||||||
|
"title": {"tag": "plain_text", "content": "🔐 权限审批"},
|
||||||
|
"template": "orange",
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": f"**工具:** `{tool_name}`\n**参数:** {summary}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": "✅ 批准"},
|
||||||
|
"type": "primary",
|
||||||
|
"value": {"action": "approve", "conv_id": conv_id},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"text": {"tag": "plain_text", "content": "❌ 拒绝"},
|
||||||
|
"type": "danger",
|
||||||
|
"value": {"action": "deny", "conv_id": conv_id},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "note",
|
||||||
|
"elements": [
|
||||||
|
{"tag": "plain_text", "content": f"超时 {timeout}s 自动拒绝 | 也可回复 y/n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
|
async def send_file(receive_id: str, receive_id_type: str, file_path: str, file_type: str = "stream") -> None:
|
||||||
"""
|
"""
|
||||||
Upload a local file to Feishu and send it as a file message.
|
Upload a local file to Feishu and send it as a file message.
|
||||||
|
|||||||
@ -129,6 +129,25 @@ async def _process_message(user_id: str, chat_id: str, text: str) -> None:
|
|||||||
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
|
await send_text(chat_id, "chat_id", "Sorry, you are not authorized to use this bot.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Text approval fallback: user replies y/n to a pending tool approval
|
||||||
|
if text.strip().lower() in ("y", "n", "yes", "no"):
|
||||||
|
approved = text.strip().lower() in ("y", "yes")
|
||||||
|
from orchestrator.agent import agent as _agent
|
||||||
|
from agent.manager import manager as _manager
|
||||||
|
conv_id = _agent.get_active_conv(user_id)
|
||||||
|
if conv_id:
|
||||||
|
session = _manager._sessions.get(conv_id)
|
||||||
|
if (
|
||||||
|
session
|
||||||
|
and session.sdk_session
|
||||||
|
and session.sdk_session._pending_approval
|
||||||
|
and not session.sdk_session._pending_approval.done()
|
||||||
|
):
|
||||||
|
await _manager.approve(conv_id, approved)
|
||||||
|
label = "✅ 已批准" if approved else "❌ 已拒绝"
|
||||||
|
await send_text(chat_id, "chat_id", label)
|
||||||
|
return
|
||||||
|
|
||||||
from config import ROUTER_MODE
|
from config import ROUTER_MODE
|
||||||
if ROUTER_MODE:
|
if ROUTER_MODE:
|
||||||
from router.nodes import get_node_registry
|
from router.nodes import get_node_registry
|
||||||
@ -196,12 +215,48 @@ def _handle_any(data: lark.CustomizedEvent) -> None:
|
|||||||
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
|
logger.info("RAW CustomizedEvent: %s", marshaled[:500])
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_card_action(data: lark.CustomizedEvent) -> None:
|
||||||
|
"""Handle Feishu card button clicks (approval approve/deny)."""
|
||||||
|
try:
|
||||||
|
marshaled = lark.JSON.marshal(data)
|
||||||
|
if not marshaled:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = json.loads(marshaled) if isinstance(marshaled, str) else marshaled
|
||||||
|
action = payload.get("event", {}).get("action", {})
|
||||||
|
value = action.get("value", {})
|
||||||
|
|
||||||
|
action_type = value.get("action") # "approve" or "deny"
|
||||||
|
conv_id = value.get("conv_id")
|
||||||
|
|
||||||
|
if not action_type or not conv_id:
|
||||||
|
logger.debug("Card action without action/conv_id: %s", value)
|
||||||
|
return
|
||||||
|
|
||||||
|
approved = action_type == "approve"
|
||||||
|
logger.info("Card action: %s for session %s", action_type, conv_id)
|
||||||
|
|
||||||
|
if _main_loop:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
_handle_approval_async(conv_id, approved), _main_loop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error handling card action")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_approval_async(conv_id: str, approved: bool) -> None:
|
||||||
|
"""Process a card approval action."""
|
||||||
|
from agent.manager import manager
|
||||||
|
await manager.approve(conv_id, approved)
|
||||||
|
|
||||||
|
|
||||||
def build_event_handler() -> lark.EventDispatcherHandler:
|
def build_event_handler() -> lark.EventDispatcherHandler:
|
||||||
"""Construct the EventDispatcherHandler with all registered callbacks."""
|
"""Construct the EventDispatcherHandler with all registered callbacks."""
|
||||||
handler = (
|
handler = (
|
||||||
lark.EventDispatcherHandler.builder("", "")
|
lark.EventDispatcherHandler.builder("", "")
|
||||||
.register_p2_im_message_receive_v1(_handle_message)
|
.register_p2_im_message_receive_v1(_handle_message)
|
||||||
.register_p1_customized_event("im.message.receive_v1", _handle_any)
|
.register_p1_customized_event("im.message.receive_v1", _handle_any)
|
||||||
|
.register_p1_customized_event("card.action.trigger", _handle_card_action)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
return handler
|
return handler
|
||||||
|
|||||||
@ -21,6 +21,10 @@ OPENAI_API_KEY: str = _cfg["OPENAI_API_KEY"]
|
|||||||
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
|
OPENAI_MODEL: str = _cfg.get("OPENAI_MODEL", "glm-4.7")
|
||||||
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
|
WORKING_DIR: Path = Path(_cfg.get("WORKING_DIR", Path.home())).expanduser().resolve()
|
||||||
METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "")
|
METASO_API_KEY: str = _cfg.get("METASO_API_KEY", "")
|
||||||
|
ANTHROPIC_API_KEY: str = _cfg.get("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
# SDK approval timeout (seconds) for can_use_tool callback
|
||||||
|
SDK_APPROVAL_TIMEOUT: int = _cfg.get("SDK_APPROVAL_TIMEOUT", 120)
|
||||||
|
|
||||||
ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False)
|
ROUTER_MODE: bool = _cfg.get("ROUTER_MODE", False)
|
||||||
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")
|
ROUTER_SECRET: str = _cfg.get("ROUTER_SECRET", "")
|
||||||
|
|||||||
18
conftest.py
18
conftest.py
@ -1,16 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Root conftest — runs before pytest collects any test files or imports any
|
Root conftest — runs before pytest collects any test files or imports any
|
||||||
production modules. Patches config._CONFIG_PATH to point at the test keyring
|
production modules. Creates a temporary keyring.yaml from the test keyring
|
||||||
so that `import config` never tries to open the real keyring.yaml.
|
so that `import config` works without the real keyring.yaml.
|
||||||
|
|
||||||
Must live at the repo root (not inside tests/) to fire before collection.
|
Must live at the repo root (not inside tests/) to fire before collection.
|
||||||
"""
|
"""
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import importlib
|
|
||||||
|
|
||||||
_TEST_KEYRING = Path(__file__).parent / "tests" / "keyring_test.yaml"
|
_REPO_ROOT = Path(__file__).parent
|
||||||
|
_TEST_KEYRING = _REPO_ROOT / "tests" / "keyring_test.yaml"
|
||||||
|
_KEYRING = _REPO_ROOT / "keyring.yaml"
|
||||||
|
|
||||||
# Patch config before anything else imports it
|
# If the real keyring.yaml doesn't exist, copy the test version so config.py
|
||||||
import config as _config_mod
|
# can load at module import time. This file is gitignored.
|
||||||
_config_mod._CONFIG_PATH = _TEST_KEYRING
|
if not _KEYRING.exists() and _TEST_KEYRING.exists():
|
||||||
importlib.reload(_config_mod)
|
shutil.copy2(_TEST_KEYRING, _KEYRING)
|
||||||
|
|||||||
72
docs/feishu/card_callback.md
Normal file
72
docs/feishu/card_callback.md
Normal 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)的回调,并且需要立即响应返回链接预览内容,从而使终端用户看到链接预览效果。
|
||||||
|
|
||||||
|
## 回调与事件的区别
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
回调与[事件](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
docs/feishu/card_callback_communication.md
Normal file
253
docs/feishu/card_callback_communication.md
Normal 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)。
|
||||||
|
|
||||||
|
## 回调
|
||||||
|
|
||||||
|
基本信息 |
|
||||||
|
---|---
|
||||||
|
回调类型 | 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>
|
||||||
|
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>
|
||||||
|
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>
|
||||||
|
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>
|
||||||
|
|
||||||
|
响应回调的结构体示例
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
在飞书客户端进行卡片交互时,若交互出错,将返回如下图对应的错误码。错误码说明及解决方案如下表所示。
|
||||||
|
|
||||||
|

|
||||||
|
错误码仅支持飞书客户端 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><br>2. 点击右上角的 **保存**,然后点击 **发布**,确保修改生效。<br>
|
||||||
|
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)。
|
||||||
@ -76,6 +76,18 @@ NEVER use `run_shell` for bot control. NEVER use `run_command` for shell command
|
|||||||
5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
|
5. WEB / SEARCH: use `web` at most twice, then synthesize and reply.
|
||||||
6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`.
|
6. BACKGROUND TASKS: when a task starts, reply immediately — do NOT poll `task_status`.
|
||||||
|
|
||||||
|
## Progress queries
|
||||||
|
When the user asks about task progress (e.g. "怎么样了?" "做得如何?" "进度"),
|
||||||
|
use `session_progress` with the active conv_id:
|
||||||
|
- busy=true → summarize recent_tools to tell the user what CC is doing
|
||||||
|
- busy=false + last_result → summarize the result
|
||||||
|
- pending_approval is not empty → remind the user to approve/deny
|
||||||
|
- error is not empty → report the error details
|
||||||
|
|
||||||
|
## Passthrough mode
|
||||||
|
Direct mode sends messages straight to the Claude Code session (no LLM overhead).
|
||||||
|
The user gets an immediate "executing" confirmation; results are pushed to Feishu on completion.
|
||||||
|
|
||||||
Guidelines:
|
Guidelines:
|
||||||
- Relay Claude Code's output verbatim.
|
- Relay Claude Code's output verbatim.
|
||||||
- If no active session and the user sends a task without naming a directory, ask which project.
|
- If no active session and the user sends a task without naming a directory, ask which project.
|
||||||
@ -159,7 +171,9 @@ class OrchestrationAgent:
|
|||||||
# Passthrough mode: if enabled and active session, bypass LLM
|
# Passthrough mode: if enabled and active session, bypass LLM
|
||||||
if self._passthrough[user_id] and active_conv:
|
if self._passthrough[user_id] and active_conv:
|
||||||
try:
|
try:
|
||||||
reply = await manager.send(active_conv, text, user_id=user_id, direct=True)
|
from orchestrator.tools import get_current_chat
|
||||||
|
chat_id = get_current_chat()
|
||||||
|
reply = await manager.send_message(active_conv, text, user_id=user_id, chat_id=chat_id)
|
||||||
logger.info("<<< [passthrough] reply: %r", reply[:120])
|
logger.info("<<< [passthrough] reply: %r", reply[:120])
|
||||||
return reply
|
return reply
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@ -83,7 +83,6 @@ class CreateConversationInput(BaseModel):
|
|||||||
)
|
)
|
||||||
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
|
initial_message: Optional[str] = Field(None, description="Optional first message to send after spawning")
|
||||||
idle_timeout: Optional[int] = Field(None, description="Idle timeout in seconds (default 1800)")
|
idle_timeout: Optional[int] = Field(None, description="Idle timeout in seconds (default 1800)")
|
||||||
cc_timeout: Optional[float] = Field(None, description="Claude Code execution timeout in seconds (default 300)")
|
|
||||||
|
|
||||||
|
|
||||||
class SendToConversationInput(BaseModel):
|
class SendToConversationInput(BaseModel):
|
||||||
@ -108,23 +107,24 @@ class CreateConversationTool(BaseTool):
|
|||||||
)
|
)
|
||||||
args_schema: Type[BaseModel] = CreateConversationInput
|
args_schema: Type[BaseModel] = CreateConversationInput
|
||||||
|
|
||||||
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
|
def _run(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
|
||||||
raise NotImplementedError("Use async version")
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None, cc_timeout: Optional[float] = None) -> str:
|
async def _arun(self, working_dir: str, initial_message: Optional[str] = None, idle_timeout: Optional[int] = None) -> str:
|
||||||
try:
|
try:
|
||||||
resolved = _resolve_dir(working_dir)
|
resolved = _resolve_dir(working_dir)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
|
|
||||||
user_id = get_current_user()
|
user_id = get_current_user()
|
||||||
|
chat_id = get_current_chat()
|
||||||
conv_id = str(uuid.uuid4())[:8]
|
conv_id = str(uuid.uuid4())[:8]
|
||||||
await manager.create(
|
await manager.create(
|
||||||
conv_id,
|
conv_id,
|
||||||
str(resolved),
|
str(resolved),
|
||||||
owner_id=user_id or "",
|
owner_id=user_id or "",
|
||||||
idle_timeout=idle_timeout or 1800,
|
idle_timeout=idle_timeout or 1800,
|
||||||
cc_timeout=cc_timeout or 300.0,
|
chat_id=chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
result: dict = {
|
result: dict = {
|
||||||
@ -154,8 +154,9 @@ class SendToConversationTool(BaseTool):
|
|||||||
|
|
||||||
async def _arun(self, conv_id: str, message: str) -> str:
|
async def _arun(self, conv_id: str, message: str) -> str:
|
||||||
user_id = get_current_user()
|
user_id = get_current_user()
|
||||||
|
chat_id = get_current_chat()
|
||||||
try:
|
try:
|
||||||
output = await manager.send(conv_id, message, user_id=user_id)
|
output = await manager.send_and_wait(conv_id, message, user_id=user_id, chat_id=chat_id)
|
||||||
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
|
return json.dumps({"conv_id": conv_id, "response": output}, ensure_ascii=False)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
|
return json.dumps({"error": f"No active session for conv_id={conv_id!r}"})
|
||||||
@ -752,12 +753,71 @@ class RunCommandTool(BaseTool):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class SessionProgressInput(BaseModel):
|
||||||
|
conv_id: str = Field(..., description="Conversation ID to check progress")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionProgressTool(BaseTool):
|
||||||
|
name: str = "session_progress"
|
||||||
|
description: str = (
|
||||||
|
"Check the progress of a running Claude Code session. "
|
||||||
|
"Returns: busy status, elapsed time, recent tool calls, "
|
||||||
|
"recent text output, and any pending approval requests. "
|
||||||
|
"Use this when the user asks about task status or progress."
|
||||||
|
)
|
||||||
|
args_schema: Type[BaseModel] = SessionProgressInput
|
||||||
|
|
||||||
|
def _run(self, conv_id: str) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self, conv_id: str) -> str:
|
||||||
|
user_id = get_current_user()
|
||||||
|
progress = manager.get_progress(conv_id, user_id)
|
||||||
|
if progress is None:
|
||||||
|
return json.dumps({"error": f"Session {conv_id} not found"})
|
||||||
|
return json.dumps({
|
||||||
|
"busy": progress.busy,
|
||||||
|
"elapsed_seconds": int(progress.elapsed_seconds),
|
||||||
|
"current_prompt": progress.current_prompt[:100],
|
||||||
|
"recent_text": progress.text_messages[-3:],
|
||||||
|
"recent_tools": progress.tool_calls[-5:],
|
||||||
|
"last_result": progress.last_result[:500] if not progress.busy else "",
|
||||||
|
"error": progress.error,
|
||||||
|
"pending_approval": progress.pending_approval,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class InterruptConversationInput(BaseModel):
|
||||||
|
conv_id: str = Field(..., description="Conversation ID to interrupt")
|
||||||
|
|
||||||
|
|
||||||
|
class InterruptConversationTool(BaseTool):
|
||||||
|
name: str = "interrupt_conversation"
|
||||||
|
description: str = "Interrupt a running Claude Code task in a session."
|
||||||
|
args_schema: Type[BaseModel] = InterruptConversationInput
|
||||||
|
|
||||||
|
def _run(self, conv_id: str) -> str:
|
||||||
|
raise NotImplementedError("Use async version")
|
||||||
|
|
||||||
|
async def _arun(self, conv_id: str) -> str:
|
||||||
|
user_id = get_current_user()
|
||||||
|
try:
|
||||||
|
success = await manager.interrupt(conv_id, user_id)
|
||||||
|
return "Interrupted" if success else "No active task to interrupt"
|
||||||
|
except KeyError:
|
||||||
|
return f"Session {conv_id} not found"
|
||||||
|
except PermissionError as e:
|
||||||
|
return str(e)
|
||||||
|
|
||||||
|
|
||||||
# Module-level tool list for easy import
|
# Module-level tool list for easy import
|
||||||
TOOLS = [
|
TOOLS = [
|
||||||
CreateConversationTool(),
|
CreateConversationTool(),
|
||||||
SendToConversationTool(),
|
SendToConversationTool(),
|
||||||
ListConversationsTool(),
|
ListConversationsTool(),
|
||||||
CloseConversationTool(),
|
CloseConversationTool(),
|
||||||
|
SessionProgressTool(),
|
||||||
|
InterruptConversationTool(),
|
||||||
RunCommandTool(),
|
RunCommandTool(),
|
||||||
ShellTool(),
|
ShellTool(),
|
||||||
FileReadTool(),
|
FileReadTool(),
|
||||||
|
|||||||
@ -1,61 +1,43 @@
|
|||||||
"""
|
"""
|
||||||
Master test fixtures for PhoneWork BDD tests.
|
Shared test fixtures for PhoneWork tests.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from unittest.mock import AsyncMock, patch
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
TESTS_DIR = Path(__file__).parent
|
|
||||||
CASSETTES_DIR = TESTS_DIR / "cassettes"
|
|
||||||
CASSETTES_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Feishu send mock ─────────────────────────────────────────────────────────
|
# ── Feishu send mock ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def feishu_calls():
|
def feishu_calls():
|
||||||
"""
|
"""Capture all calls to bot.feishu send functions."""
|
||||||
Capture all calls to bot.feishu send functions.
|
captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []}
|
||||||
Lazy imports inside commands.py pull from bot.feishu at call time,
|
|
||||||
so patching the module attributes is sufficient.
|
|
||||||
"""
|
|
||||||
captured: dict[str, list] = {"texts": [], "cards": [], "files": []}
|
|
||||||
|
|
||||||
async def mock_send_text(receive_id, receive_id_type, text):
|
async def mock_send_text(receive_id, receive_id_type, text):
|
||||||
captured["texts"].append({"receive_id": receive_id, "text": text})
|
captured["texts"].append(text)
|
||||||
|
|
||||||
|
async def mock_send_markdown(receive_id, receive_id_type, content):
|
||||||
|
captured["markdowns"].append(content)
|
||||||
|
|
||||||
async def mock_send_card(receive_id, receive_id_type, card):
|
async def mock_send_card(receive_id, receive_id_type, card):
|
||||||
captured["cards"].append({"receive_id": receive_id, "card": card})
|
captured["cards"].append(card)
|
||||||
|
|
||||||
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
|
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
|
||||||
captured["files"].append({"receive_id": receive_id, "file_path": file_path})
|
captured["files"].append(file_path)
|
||||||
|
|
||||||
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
|
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
|
||||||
|
patch("bot.feishu.send_markdown", side_effect=mock_send_markdown), \
|
||||||
patch("bot.feishu.send_card", side_effect=mock_send_card), \
|
patch("bot.feishu.send_card", side_effect=mock_send_card), \
|
||||||
patch("bot.feishu.send_file", side_effect=mock_send_file):
|
patch("bot.feishu.send_file", side_effect=mock_send_file), \
|
||||||
|
patch("bot.handler.send_text", side_effect=mock_send_text), \
|
||||||
|
patch("bot.handler.send_markdown", side_effect=mock_send_markdown):
|
||||||
yield captured
|
yield captured
|
||||||
|
|
||||||
|
|
||||||
# ── run_claude mock ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_run_claude():
|
|
||||||
"""
|
|
||||||
Replace run_claude in both its definition and its import site in manager.py.
|
|
||||||
Default return value is a short CC-style output string.
|
|
||||||
"""
|
|
||||||
mock = AsyncMock(return_value="Claude Code: task complete.")
|
|
||||||
with patch("agent.cc_runner.run_claude", mock), \
|
|
||||||
patch("agent.manager.run_claude", mock):
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
# ── Singleton state resets ───────────────────────────────────────────────────
|
# ── Singleton state resets ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -110,13 +92,6 @@ def reset_contextvars():
|
|||||||
set_current_chat(None)
|
set_current_chat(None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_reply(pytestconfig):
|
|
||||||
"""Clear _reply before each test so stale values don't leak between scenarios."""
|
|
||||||
pytestconfig._reply = None
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
# ── Working directory isolation ──────────────────────────────────────────────
|
# ── Working directory isolation ──────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -127,41 +102,3 @@ def tmp_working_dir(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
|
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
|
||||||
(tmp_path / "myproject").mkdir()
|
(tmp_path / "myproject").mkdir()
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
# ── VCR cassette factory ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def make_vcr_cassette(cassette_name: str):
|
|
||||||
"""
|
|
||||||
Return a vcrpy context manager for the given cassette name.
|
|
||||||
Set VCR_RECORD_MODE=new_episodes locally to record; CI uses 'none'.
|
|
||||||
Authorization headers are stripped so cassettes are safe to commit.
|
|
||||||
If the cassette doesn't exist in 'none' mode, the test is skipped.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
try:
|
|
||||||
import vcr
|
|
||||||
except ImportError:
|
|
||||||
import pytest
|
|
||||||
pytest.skip("vcrpy not installed")
|
|
||||||
|
|
||||||
record_mode = os.environ.get("VCR_RECORD_MODE", "none")
|
|
||||||
cassette_path = CASSETTES_DIR / cassette_name
|
|
||||||
cassette_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if record_mode == "none" and not cassette_path.exists():
|
|
||||||
import pytest
|
|
||||||
pytest.skip(f"No cassette recorded yet: {cassette_name}. Run with VCR_RECORD_MODE=new_episodes to record.")
|
|
||||||
|
|
||||||
my_vcr = vcr.VCR(
|
|
||||||
record_mode=record_mode,
|
|
||||||
match_on=["method", "scheme", "host", "port", "path", "body"],
|
|
||||||
filter_headers=["authorization", "x-api-key"],
|
|
||||||
decode_compressed_response=True,
|
|
||||||
)
|
|
||||||
return my_vcr.use_cassette(str(cassette_path))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def vcr_cassette():
|
|
||||||
return make_vcr_cassette
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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 "❌"
|
|
||||||
@ -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"
|
|
||||||
@ -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)}"
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"""
|
|
||||||
Step definitions for all slash command features.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from pytest_bdd import scenarios, given, when, then, parsers
|
|
||||||
|
|
||||||
from tests.step_defs.common_steps import * # noqa: F401,F403 — import shared steps
|
|
||||||
|
|
||||||
scenarios(
|
|
||||||
"../features/commands/new.feature",
|
|
||||||
"../features/commands/status.feature",
|
|
||||||
"../features/commands/switch.feature",
|
|
||||||
"../features/commands/close.feature",
|
|
||||||
"../features/commands/direct_smart.feature",
|
|
||||||
"../features/commands/shell.feature",
|
|
||||||
"../features/commands/remind.feature",
|
|
||||||
"../features/commands/tasks.feature",
|
|
||||||
"../features/commands/nodes.feature",
|
|
||||||
"../features/commands/help.feature",
|
|
||||||
"../features/commands/perm.feature",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── When: send slash command ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@when(parsers.parse('user sends "{text}"'))
|
|
||||||
def send_command(text, pytestconfig, feishu_calls, mock_run_claude):
|
|
||||||
import asyncio
|
|
||||||
from bot.commands import handle_command
|
|
||||||
user_id = getattr(pytestconfig, "_test_user_id", "user_abc123")
|
|
||||||
reply = asyncio.get_event_loop().run_until_complete(handle_command(user_id, text))
|
|
||||||
pytestconfig._reply = reply
|
|
||||||
|
|
||||||
|
|
||||||
# ── Given: task runner state ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@given(parsers.parse('there is a running task "{task_id}" described as "{desc}"'))
|
|
||||||
def add_running_task(task_id, desc):
|
|
||||||
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
|
||||||
task = BackgroundTask(
|
|
||||||
task_id=task_id,
|
|
||||||
description=desc,
|
|
||||||
started_at=time.time(),
|
|
||||||
status=TaskStatus.RUNNING,
|
|
||||||
)
|
|
||||||
task_runner._tasks[task_id] = task
|
|
||||||
|
|
||||||
|
|
||||||
@given(parsers.parse('there is a completed task "{task_id}" described as "{desc}"'))
|
|
||||||
def add_completed_task(task_id, desc):
|
|
||||||
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
|
||||||
now = time.time()
|
|
||||||
task = BackgroundTask(
|
|
||||||
task_id=task_id,
|
|
||||||
description=desc,
|
|
||||||
started_at=now - 5,
|
|
||||||
status=TaskStatus.COMPLETED,
|
|
||||||
completed_at=now,
|
|
||||||
result="success",
|
|
||||||
)
|
|
||||||
task_runner._tasks[task_id] = task
|
|
||||||
|
|
||||||
|
|
||||||
@given(parsers.parse('there is a failed task "{task_id}" described as "{desc}"'))
|
|
||||||
def add_failed_task(task_id, desc):
|
|
||||||
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
|
||||||
now = time.time()
|
|
||||||
task = BackgroundTask(
|
|
||||||
task_id=task_id,
|
|
||||||
description=desc,
|
|
||||||
started_at=now - 3,
|
|
||||||
status=TaskStatus.FAILED,
|
|
||||||
completed_at=now,
|
|
||||||
error="subprocess failed",
|
|
||||||
)
|
|
||||||
task_runner._tasks[task_id] = task
|
|
||||||
384
tests/test_commands.py
Normal file
384
tests/test_commands.py
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
"""Tests for bot slash commands (replaces BDD feature tests).
|
||||||
|
|
||||||
|
Covers: //help, //new, //close, //switch, //status, //perm,
|
||||||
|
//direct, //smart, //shell, //remind, //tasks, //stop, //progress, //nodes
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user, set_current_chat
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_user(user_id="user_abc123", chat_id=None):
|
||||||
|
set_current_user(user_id)
|
||||||
|
if chat_id:
|
||||||
|
set_current_chat(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_session(conv_id, cwd="/tmp/proj", user_id="user_abc123", activate=False):
|
||||||
|
session = Session(conv_id=conv_id, cwd=cwd, owner_id=user_id)
|
||||||
|
manager._sessions[conv_id] = session
|
||||||
|
if activate:
|
||||||
|
agent._active_conv[user_id] = conv_id
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── //help ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestHelp:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_lists_commands(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//help")
|
||||||
|
for cmd in ("//new", "//status", "//close", "//switch", "//perm",
|
||||||
|
"//stop", "//progress", "//direct", "//smart", "//shell"):
|
||||||
|
assert cmd in reply, f"Missing {cmd} in help"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_h_alias(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//h")
|
||||||
|
assert "//new" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_command_returns_none(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//unknown_xyz")
|
||||||
|
assert reply is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── //new ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestNew:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_args_shows_usage(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//new")
|
||||||
|
assert "Usage" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creates_session(self, tmp_working_dir):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//new myproject")
|
||||||
|
assert "myproject" in reply
|
||||||
|
assert len(manager.list_sessions(user_id="user_abc123")) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_path_traversal_blocked(self, tmp_working_dir):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//new ../../etc")
|
||||||
|
assert "Error" in reply
|
||||||
|
assert len(manager.list_sessions(user_id="user_abc123")) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_with_perm_flag(self, tmp_working_dir):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//new myproject --perm plan")
|
||||||
|
sessions = manager.list_sessions(user_id="user_abc123")
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0]["permission_mode"] == "plan"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_card_when_chat_set(self, tmp_working_dir, feishu_calls):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user(chat_id="chat1")
|
||||||
|
reply = await handle_command("user_abc123", "//new myproject")
|
||||||
|
assert reply == "" # card was sent instead
|
||||||
|
assert len(feishu_calls["cards"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── //close ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestClose:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_sessions(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//close")
|
||||||
|
assert "No sessions" in reply or "No active" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_active(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//close")
|
||||||
|
assert "Closed" in reply
|
||||||
|
assert len(manager.list_sessions(user_id="user_abc123")) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_by_number(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
_add_session("s2")
|
||||||
|
reply = await handle_command("user_abc123", "//close 1")
|
||||||
|
assert "Closed" in reply
|
||||||
|
assert len(manager.list_sessions(user_id="user_abc123")) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_number(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
reply = await handle_command("user_abc123", "//close 9")
|
||||||
|
assert "Invalid" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_close_other_user(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", user_id="other_user")
|
||||||
|
reply = await handle_command("user_abc123", "//close s1")
|
||||||
|
assert "another user" in reply or "not found" in reply.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── //switch ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSwitch:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_sessions(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//switch 1")
|
||||||
|
assert "No sessions" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_switch(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
_add_session("s2")
|
||||||
|
reply = await handle_command("user_abc123", "//switch 2")
|
||||||
|
assert "Switched" in reply
|
||||||
|
assert agent._active_conv["user_abc123"] == "s2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_out_of_range(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
reply = await handle_command("user_abc123", "//switch 5")
|
||||||
|
assert "Invalid" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_numeric(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
reply = await handle_command("user_abc123", "//switch abc")
|
||||||
|
assert "Invalid" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── //status ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_sessions(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//status")
|
||||||
|
assert "No active sessions" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shows_sessions(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1")
|
||||||
|
_add_session("s2")
|
||||||
|
reply = await handle_command("user_abc123", "//status")
|
||||||
|
assert "s1" in reply
|
||||||
|
assert "s2" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shows_active_marker(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//status")
|
||||||
|
assert "→" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── //perm ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestPerm:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_args_shows_usage(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//perm")
|
||||||
|
assert "Usage" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_edit(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//perm edit")
|
||||||
|
assert "edit" in reply
|
||||||
|
assert manager._sessions["s1"].permission_mode == "acceptEdits"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_plan(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//perm plan")
|
||||||
|
assert "plan" in reply
|
||||||
|
assert manager._sessions["s1"].permission_mode == "plan"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_auto(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//perm auto")
|
||||||
|
assert "auto" in reply
|
||||||
|
assert manager._sessions["s1"].permission_mode == "dontAsk"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_mode(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//perm xyz")
|
||||||
|
assert "Unknown" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_active_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//perm edit")
|
||||||
|
assert "No active session" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── //direct + //smart ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestDirectSmart:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_requires_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//direct")
|
||||||
|
assert "No active session" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_enables_passthrough(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
reply = await handle_command("user_abc123", "//direct")
|
||||||
|
assert "Direct mode ON" in reply
|
||||||
|
assert agent._passthrough["user_abc123"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_smart_disables_passthrough(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
_add_session("s1", activate=True)
|
||||||
|
agent._passthrough["user_abc123"] = True
|
||||||
|
reply = await handle_command("user_abc123", "//smart")
|
||||||
|
assert "Smart mode ON" in reply
|
||||||
|
assert agent._passthrough["user_abc123"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── //shell ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestShell:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_args_shows_usage(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//shell")
|
||||||
|
assert "Usage" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_echo(self, tmp_working_dir):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//shell echo hello")
|
||||||
|
assert "hello" in reply
|
||||||
|
assert "exit code: 0" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blocked_dangerous(self, tmp_working_dir):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//shell rm -rf /")
|
||||||
|
assert "Blocked" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── //remind ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestRemind:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_args(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user(chat_id="chat1")
|
||||||
|
reply = await handle_command("user_abc123", "//remind")
|
||||||
|
assert "Usage" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_message(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user(chat_id="chat1")
|
||||||
|
reply = await handle_command("user_abc123", "//remind 10m")
|
||||||
|
assert "Usage" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_reminder(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.scheduler import scheduler
|
||||||
|
_setup_user(chat_id="chat1")
|
||||||
|
reply = await handle_command("user_abc123", "//remind 30s check build")
|
||||||
|
assert "Reminder" in reply
|
||||||
|
assert len(scheduler._jobs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── //tasks ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestTasks:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_tasks(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//tasks")
|
||||||
|
assert "No background tasks" in reply
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_shows_running_task(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.task_runner import task_runner, BackgroundTask, TaskStatus
|
||||||
|
_setup_user()
|
||||||
|
task_runner._tasks["t1"] = BackgroundTask(
|
||||||
|
task_id="t1", description="fix bug", started_at=time.time(),
|
||||||
|
status=TaskStatus.RUNNING,
|
||||||
|
)
|
||||||
|
reply = await handle_command("user_abc123", "//tasks")
|
||||||
|
assert "t1" in reply
|
||||||
|
assert "⏳" in reply
|
||||||
|
|
||||||
|
|
||||||
|
# ── //nodes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestNodes:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nodes_outside_router_mode(self, monkeypatch):
|
||||||
|
import config
|
||||||
|
monkeypatch.setattr(config, "ROUTER_MODE", False)
|
||||||
|
from bot.commands import handle_command
|
||||||
|
_setup_user()
|
||||||
|
reply = await handle_command("user_abc123", "//nodes")
|
||||||
|
assert "Not in router mode" in reply
|
||||||
816
tests/test_sdk_migration.py
Normal file
816
tests/test_sdk_migration.py
Normal file
@ -0,0 +1,816 @@
|
|||||||
|
"""Unit tests for the SDK migration (secretary model).
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- SDKSession lifecycle, message buffering, get_progress, approval
|
||||||
|
- sdk_hooks audit + deny
|
||||||
|
- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve)
|
||||||
|
- audit.py new functions (log_tool_use, log_permission_decision)
|
||||||
|
- bot/commands.py new commands (//stop, //progress, //perm auto)
|
||||||
|
- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool)
|
||||||
|
- bot/handler.py text approval fallback
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_audit_dir(tmp_path):
|
||||||
|
"""Redirect audit logs to a temp directory."""
|
||||||
|
import agent.audit as audit_mod
|
||||||
|
original = audit_mod.AUDIT_DIR
|
||||||
|
audit_mod.AUDIT_DIR = tmp_path / "audit"
|
||||||
|
yield tmp_path / "audit"
|
||||||
|
audit_mod.AUDIT_DIR = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_sdk_client():
|
||||||
|
"""Create a mock ClaudeSDKClient that yields controllable messages."""
|
||||||
|
client = AsyncMock()
|
||||||
|
client.connect = AsyncMock()
|
||||||
|
client.disconnect = AsyncMock()
|
||||||
|
client.query = AsyncMock()
|
||||||
|
client.interrupt = AsyncMock()
|
||||||
|
client.set_permission_mode = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feishu():
|
||||||
|
"""Mock all Feishu send functions."""
|
||||||
|
captured = {"texts": [], "cards": [], "markdowns": []}
|
||||||
|
|
||||||
|
async def _send_text(rid, rtype, text):
|
||||||
|
captured["texts"].append(text)
|
||||||
|
|
||||||
|
async def _send_card(rid, rtype, card):
|
||||||
|
captured["cards"].append(card)
|
||||||
|
|
||||||
|
async def _send_markdown(rid, rtype, content):
|
||||||
|
captured["markdowns"].append(content)
|
||||||
|
|
||||||
|
with patch("bot.feishu.send_text", side_effect=_send_text), \
|
||||||
|
patch("bot.feishu.send_card", side_effect=_send_card), \
|
||||||
|
patch("bot.feishu.send_markdown", side_effect=_send_markdown), \
|
||||||
|
patch("bot.handler.send_text", side_effect=_send_text), \
|
||||||
|
patch("bot.handler.send_markdown", side_effect=_send_markdown):
|
||||||
|
yield captured
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 1. SDKSession unit tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKSessionProgress:
|
||||||
|
"""Test SDKSession.get_progress() with buffered messages."""
|
||||||
|
|
||||||
|
def test_initial_progress_is_idle(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
p = s.get_progress()
|
||||||
|
assert p.busy is False
|
||||||
|
assert p.current_prompt == ""
|
||||||
|
assert p.text_messages == []
|
||||||
|
assert p.tool_calls == []
|
||||||
|
|
||||||
|
def test_progress_after_manual_state_change(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
s._busy = True
|
||||||
|
s._current_prompt = "write hello.py"
|
||||||
|
s._started_at = time.time() - 5
|
||||||
|
s._text_buffer = ["I'll create the file", "Done"]
|
||||||
|
s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"]
|
||||||
|
p = s.get_progress()
|
||||||
|
assert p.busy is True
|
||||||
|
assert p.current_prompt == "write hello.py"
|
||||||
|
assert p.elapsed_seconds >= 4
|
||||||
|
assert len(p.text_messages) == 2
|
||||||
|
assert len(p.tool_calls) == 2
|
||||||
|
|
||||||
|
def test_buffer_limits(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
for i in range(30):
|
||||||
|
s._text_buffer.append(f"text-{i}")
|
||||||
|
if len(s._text_buffer) > s.MAX_BUFFER_TEXTS:
|
||||||
|
s._text_buffer.pop(0)
|
||||||
|
assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS
|
||||||
|
assert s._text_buffer[0] == "text-10"
|
||||||
|
|
||||||
|
def test_progress_pending_approval(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
s._pending_approval_desc = "Bash: `rm -rf /tmp/test`"
|
||||||
|
p = s.get_progress()
|
||||||
|
assert p.pending_approval == "Bash: `rm -rf /tmp/test`"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKSessionApproval:
|
||||||
|
"""Test the approval mechanism."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_resolves_future(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
s._pending_approval = loop.create_future()
|
||||||
|
await s.approve(True)
|
||||||
|
assert s._pending_approval.result() is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_deny(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
s._pending_approval = loop.create_future()
|
||||||
|
await s.approve(False)
|
||||||
|
assert s._pending_approval.result() is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_no_pending_is_noop(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
# Should not raise
|
||||||
|
await s.approve(True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKSessionClose:
|
||||||
|
"""Test clean shutdown."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_without_start(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
# Should not raise
|
||||||
|
await s.close()
|
||||||
|
assert s.client is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_disconnects_client(self, mock_sdk_client):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
s.client = mock_sdk_client
|
||||||
|
s._message_loop_task = None
|
||||||
|
await s.close()
|
||||||
|
mock_sdk_client.disconnect.assert_awaited_once()
|
||||||
|
assert s.client is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKSessionSend:
|
||||||
|
"""Test send() and send_and_wait() with mocked client."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_returns_immediately(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
|
||||||
|
s = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
||||||
|
|
||||||
|
# Provide a mock client that has receive_messages yielding nothing
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.query = AsyncMock()
|
||||||
|
|
||||||
|
async def _empty_messages():
|
||||||
|
return
|
||||||
|
yield # make it an async generator
|
||||||
|
|
||||||
|
mock_client.receive_messages = _empty_messages
|
||||||
|
s.client = mock_client
|
||||||
|
|
||||||
|
result = await s.send("hello")
|
||||||
|
assert "已开始执行" in result
|
||||||
|
assert s._busy is True
|
||||||
|
assert s._current_prompt == "hello"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if s._message_loop_task:
|
||||||
|
s._message_loop_task.cancel()
|
||||||
|
try:
|
||||||
|
await s._message_loop_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKSessionFormatSummary:
|
||||||
|
"""Test _format_tool_summary."""
|
||||||
|
|
||||||
|
def test_bash_summary(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
result = s._format_tool_summary("Bash", {"command": "ls -la"})
|
||||||
|
assert "`ls -la`" in result
|
||||||
|
|
||||||
|
def test_edit_summary(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"})
|
||||||
|
assert "test.py" in result
|
||||||
|
|
||||||
|
def test_other_summary_truncated(self):
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
s = SDKSession("c1", "/tmp", "u1")
|
||||||
|
result = s._format_tool_summary("CustomTool", {"key": "x" * 500})
|
||||||
|
assert len(result) <= 200
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 2. sdk_hooks tests
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSDKHooks:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_hook_logs(self, tmp_audit_dir):
|
||||||
|
from agent.sdk_hooks import audit_hook
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"session_id": "test-session",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input": {"command": "echo hello"},
|
||||||
|
"tool_response": "hello\n",
|
||||||
|
}
|
||||||
|
result = await audit_hook(input_data, "tu-1", {"signal": None})
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
# Check JSONL was written
|
||||||
|
log_file = tmp_audit_dir / "test-session.jsonl"
|
||||||
|
assert log_file.exists()
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry["type"] == "tool_use"
|
||||||
|
assert entry["tool_name"] == "Bash"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deny_dangerous_rm_rf(self):
|
||||||
|
from agent.sdk_hooks import deny_dangerous_hook
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input": {"command": "rm -rf /"},
|
||||||
|
}
|
||||||
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
||||||
|
assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deny_allows_safe_commands(self):
|
||||||
|
from agent.sdk_hooks import deny_dangerous_hook
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input": {"command": "ls -la /tmp"},
|
||||||
|
}
|
||||||
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deny_ignores_non_bash(self):
|
||||||
|
from agent.sdk_hooks import deny_dangerous_hook
|
||||||
|
|
||||||
|
input_data = {
|
||||||
|
"tool_name": "Edit",
|
||||||
|
"tool_input": {"file_path": "/etc/passwd"},
|
||||||
|
}
|
||||||
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_build_hooks_returns_expected_structure(self):
|
||||||
|
from agent.sdk_hooks import build_hooks
|
||||||
|
|
||||||
|
hooks = build_hooks("test-conv")
|
||||||
|
assert "PostToolUse" in hooks
|
||||||
|
assert "PreToolUse" in hooks
|
||||||
|
assert len(hooks["PostToolUse"]) == 1
|
||||||
|
assert len(hooks["PreToolUse"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 3. manager tests (new methods)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionManagerNew:
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
manager._sessions.clear()
|
||||||
|
yield
|
||||||
|
manager._sessions.clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_session_no_cc_timeout(self):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1")
|
||||||
|
assert s.conv_id == "c1"
|
||||||
|
assert s.chat_id == "chat1"
|
||||||
|
assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_progress_no_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
result = manager.get_progress("nonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_progress_no_sdk_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
||||||
|
p = manager.get_progress("c1", user_id="u1")
|
||||||
|
assert p is not None
|
||||||
|
assert p.busy is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interrupt_no_sdk_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
||||||
|
result = await manager.interrupt("c1", user_id="u1")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_no_sdk_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
||||||
|
# Should not raise
|
||||||
|
await manager.approve("c1", True)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_with_sdk_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
||||||
|
mock_sdk = MagicMock(spec=SDKSession)
|
||||||
|
mock_sdk.close = AsyncMock()
|
||||||
|
manager._sessions["c1"].sdk_session = mock_sdk
|
||||||
|
|
||||||
|
result = await manager.close("c1", user_id="u1")
|
||||||
|
assert result is True
|
||||||
|
mock_sdk.close.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_permission_mode_with_sdk_session(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
||||||
|
|
||||||
|
mock_sdk = MagicMock(spec=SDKSession)
|
||||||
|
mock_sdk.set_permission_mode = AsyncMock()
|
||||||
|
manager._sessions["c1"].sdk_session = mock_sdk
|
||||||
|
|
||||||
|
manager.set_permission_mode("c1", "acceptEdits", user_id="u1")
|
||||||
|
assert manager._sessions["c1"].permission_mode == "acceptEdits"
|
||||||
|
|
||||||
|
def test_list_sessions_includes_busy(self):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
mock_sdk = MagicMock(spec=SDKSession)
|
||||||
|
mock_sdk._busy = True
|
||||||
|
session.sdk_session = mock_sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
result = manager.list_sessions()
|
||||||
|
assert result[0]["busy"] is True
|
||||||
|
|
||||||
|
def test_session_from_dict_strips_old_fields(self):
|
||||||
|
from agent.manager import Session
|
||||||
|
old_data = {
|
||||||
|
"conv_id": "c1",
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"owner_id": "u1",
|
||||||
|
"cc_session_id": "old-uuid",
|
||||||
|
"started": True,
|
||||||
|
"cc_timeout": 300.0,
|
||||||
|
"last_activity": 0.0,
|
||||||
|
"idle_timeout": 1800,
|
||||||
|
"permission_mode": "default",
|
||||||
|
}
|
||||||
|
s = Session.from_dict(old_data)
|
||||||
|
assert s.conv_id == "c1"
|
||||||
|
assert not hasattr(s, "cc_session_id")
|
||||||
|
|
||||||
|
def test_session_to_dict_excludes_sdk_session(self):
|
||||||
|
from agent.manager import Session
|
||||||
|
s = Session(conv_id="c1", cwd="/tmp")
|
||||||
|
s.sdk_session = MagicMock()
|
||||||
|
d = s.to_dict()
|
||||||
|
assert "sdk_session" not in d
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 4. audit tests (new functions)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditNewFunctions:
|
||||||
|
|
||||||
|
def test_log_tool_use(self, tmp_audit_dir):
|
||||||
|
from agent.audit import log_tool_use
|
||||||
|
|
||||||
|
log_tool_use(
|
||||||
|
session_id="s1",
|
||||||
|
tool_name="Bash",
|
||||||
|
tool_input={"command": "echo hello"},
|
||||||
|
tool_response="hello\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
log_file = tmp_audit_dir / "s1.jsonl"
|
||||||
|
assert log_file.exists()
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry["type"] == "tool_use"
|
||||||
|
assert entry["tool_name"] == "Bash"
|
||||||
|
|
||||||
|
def test_log_permission_decision_approved(self, tmp_audit_dir):
|
||||||
|
from agent.audit import log_permission_decision
|
||||||
|
|
||||||
|
log_permission_decision(
|
||||||
|
conv_id="c1",
|
||||||
|
tool_name="Bash",
|
||||||
|
tool_input={"command": "rm test.txt"},
|
||||||
|
approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_file = tmp_audit_dir / "c1.jsonl"
|
||||||
|
assert log_file.exists()
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry["type"] == "permission_decision"
|
||||||
|
assert entry["approved"] is True
|
||||||
|
|
||||||
|
def test_log_permission_decision_denied(self, tmp_audit_dir):
|
||||||
|
from agent.audit import log_permission_decision
|
||||||
|
|
||||||
|
log_permission_decision(
|
||||||
|
conv_id="c1",
|
||||||
|
tool_name="Write",
|
||||||
|
tool_input={"file_path": "/etc/passwd"},
|
||||||
|
approved=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_file = tmp_audit_dir / "c1.jsonl"
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry["approved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 5. bot/commands.py new commands
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewCommands:
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user, set_current_chat
|
||||||
|
manager._sessions.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
agent._passthrough.clear()
|
||||||
|
set_current_user(None)
|
||||||
|
set_current_chat(None)
|
||||||
|
yield
|
||||||
|
manager._sessions.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_no_active_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
result = await handle_command("u1", "//stop")
|
||||||
|
assert "No active session" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_with_session_no_sdk(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
|
||||||
|
set_current_user("u1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
result = await handle_command("u1", "//stop")
|
||||||
|
assert "No active task" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_no_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
result = await handle_command("u1", "//progress")
|
||||||
|
assert "No active session" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_idle_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
|
||||||
|
set_current_user("u1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
result = await handle_command("u1", "//progress")
|
||||||
|
assert "空闲" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_busy_session(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
|
||||||
|
set_current_user("u1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
sdk = SDKSession("c1", "/tmp", "u1")
|
||||||
|
sdk._busy = True
|
||||||
|
sdk._started_at = time.time() - 10
|
||||||
|
sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"]
|
||||||
|
session.sdk_session = sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
result = await handle_command("u1", "//progress")
|
||||||
|
assert "执行中" in result
|
||||||
|
assert "Bash" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_progress_with_pending_approval(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
|
||||||
|
set_current_user("u1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
sdk = SDKSession("c1", "/tmp", "u1")
|
||||||
|
sdk._busy = True
|
||||||
|
sdk._started_at = time.time() - 5
|
||||||
|
sdk._pending_approval_desc = "Bash: `rm test`"
|
||||||
|
session.sdk_session = sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
result = await handle_command("u1", "//progress")
|
||||||
|
assert "审批" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perm_auto_alias(self):
|
||||||
|
from bot.commands import handle_command
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_user
|
||||||
|
|
||||||
|
set_current_user("u1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
result = await handle_command("u1", "//perm auto")
|
||||||
|
assert "auto" in result
|
||||||
|
assert manager._sessions["c1"].permission_mode == "dontAsk"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 6. orchestrator/tools.py new tools
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewTools:
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
from orchestrator.tools import set_current_user, set_current_chat
|
||||||
|
manager._sessions.clear()
|
||||||
|
set_current_user("u1")
|
||||||
|
set_current_chat("chat1")
|
||||||
|
yield
|
||||||
|
manager._sessions.clear()
|
||||||
|
set_current_user(None)
|
||||||
|
set_current_chat(None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_progress_not_found(self):
|
||||||
|
from orchestrator.tools import SessionProgressTool
|
||||||
|
tool = SessionProgressTool()
|
||||||
|
result = await tool._arun("nonexistent")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_progress_idle(self):
|
||||||
|
from orchestrator.tools import SessionProgressTool
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
|
||||||
|
tool = SessionProgressTool()
|
||||||
|
result = await tool._arun("c1")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["busy"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_progress_busy(self):
|
||||||
|
from orchestrator.tools import SessionProgressTool
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
sdk = SDKSession("c1", "/tmp", "u1")
|
||||||
|
sdk._busy = True
|
||||||
|
sdk._started_at = time.time() - 30
|
||||||
|
sdk._current_prompt = "fix the bug"
|
||||||
|
sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"]
|
||||||
|
session.sdk_session = sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
|
||||||
|
tool = SessionProgressTool()
|
||||||
|
result = await tool._arun("c1")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["busy"] is True
|
||||||
|
assert data["elapsed_seconds"] >= 29
|
||||||
|
assert "Edit" in str(data["recent_tools"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interrupt_not_found(self):
|
||||||
|
from orchestrator.tools import InterruptConversationTool
|
||||||
|
tool = InterruptConversationTool()
|
||||||
|
result = await tool._arun("nonexistent")
|
||||||
|
assert "not found" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interrupt_no_active_task(self):
|
||||||
|
from orchestrator.tools import InterruptConversationTool
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
|
||||||
|
tool = InterruptConversationTool()
|
||||||
|
result = await tool._arun("c1")
|
||||||
|
assert "No active task" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 7. bot/handler.py text approval fallback
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextApprovalFallback:
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset(self):
|
||||||
|
from agent.manager import manager
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_chat
|
||||||
|
manager._sessions.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
set_current_chat(None)
|
||||||
|
yield
|
||||||
|
manager._sessions.clear()
|
||||||
|
agent._active_conv.clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_y_resolves_pending_approval(self, mock_feishu):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_chat
|
||||||
|
|
||||||
|
set_current_chat("chat1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
sdk._pending_approval = loop.create_future()
|
||||||
|
session.sdk_session = sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
from bot.handler import _process_message
|
||||||
|
await _process_message("u1", "chat1", "y")
|
||||||
|
|
||||||
|
assert sdk._pending_approval.done()
|
||||||
|
assert sdk._pending_approval.result() is True
|
||||||
|
# handler calls send_text which is mocked separately from send_markdown
|
||||||
|
assert any("批准" in t for t in mock_feishu["texts"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_n_denies_pending_approval(self, mock_feishu):
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from agent.sdk_session import SDKSession
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_chat
|
||||||
|
|
||||||
|
set_current_chat("chat1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
sdk._pending_approval = loop.create_future()
|
||||||
|
session.sdk_session = sdk
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
from bot.handler import _process_message
|
||||||
|
await _process_message("u1", "chat1", "n")
|
||||||
|
|
||||||
|
assert sdk._pending_approval.done()
|
||||||
|
assert sdk._pending_approval.result() is False
|
||||||
|
assert any("拒绝" in t for t in mock_feishu["texts"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_y_without_pending_falls_through(self, mock_feishu):
|
||||||
|
"""If there's no pending approval, 'y' should not be consumed."""
|
||||||
|
from agent.manager import manager, Session
|
||||||
|
from orchestrator.agent import agent
|
||||||
|
from orchestrator.tools import set_current_chat
|
||||||
|
|
||||||
|
set_current_chat("chat1")
|
||||||
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
||||||
|
manager._sessions["c1"] = session
|
||||||
|
agent._active_conv["u1"] = "c1"
|
||||||
|
|
||||||
|
from bot.handler import _process_message
|
||||||
|
# Patch agent.run to avoid actual LLM call
|
||||||
|
with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"):
|
||||||
|
await _process_message("u1", "chat1", "y")
|
||||||
|
|
||||||
|
# Should not have consumed as approval
|
||||||
|
assert not any("批准" in t for t in mock_feishu["markdowns"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 8. bot/feishu.py build_approval_card
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildApprovalCard:
|
||||||
|
|
||||||
|
def test_card_structure(self):
|
||||||
|
from bot.feishu import build_approval_card
|
||||||
|
card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60)
|
||||||
|
|
||||||
|
assert card["schema"] == "2.0"
|
||||||
|
assert "权限审批" in card["header"]["title"]["content"]
|
||||||
|
|
||||||
|
body_elements = card["body"]["elements"]
|
||||||
|
# Should have markdown, action, and note elements
|
||||||
|
tags = [e["tag"] for e in body_elements]
|
||||||
|
assert "markdown" in tags
|
||||||
|
assert "action" in tags
|
||||||
|
assert "note" in tags
|
||||||
|
|
||||||
|
# Action should have 2 buttons
|
||||||
|
action_el = next(e for e in body_elements if e["tag"] == "action")
|
||||||
|
assert len(action_el["actions"]) == 2
|
||||||
|
# First button should be approve
|
||||||
|
assert action_el["actions"][0]["value"]["action"] == "approve"
|
||||||
|
assert action_el["actions"][0]["value"]["conv_id"] == "c1"
|
||||||
|
# Second button should be deny
|
||||||
|
assert action_el["actions"][1]["value"]["action"] == "deny"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 9. Permission mode constants
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionModes:
|
||||||
|
|
||||||
|
def test_valid_modes_includes_dontask(self):
|
||||||
|
from agent.sdk_session import VALID_PERMISSION_MODES
|
||||||
|
assert "dontAsk" in VALID_PERMISSION_MODES
|
||||||
|
|
||||||
|
def test_perm_aliases_has_auto(self):
|
||||||
|
from bot.commands import _PERM_ALIASES
|
||||||
|
assert _PERM_ALIASES["auto"] == "dontAsk"
|
||||||
|
|
||||||
|
def test_perm_labels_has_dontask(self):
|
||||||
|
from bot.commands import _PERM_LABELS
|
||||||
|
assert _PERM_LABELS["dontAsk"] == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 10. Config
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
|
||||||
|
def test_sdk_approval_timeout_exists(self):
|
||||||
|
from config import SDK_APPROVAL_TIMEOUT
|
||||||
|
assert isinstance(SDK_APPROVAL_TIMEOUT, int)
|
||||||
|
assert SDK_APPROVAL_TIMEOUT > 0
|
||||||
Loading…
x
Reference in New Issue
Block a user