feat(feishu): update card schema to 2.0 and simplify approval card structure docs(feishu): add documentation for card json schema 2.0 changes
900 lines
31 KiB
Python
900 lines
31 KiB
Python
"""Unit tests for the SDK migration (secretary model).
|
|
|
|
Tests cover:
|
|
- SDKSession lifecycle, message buffering, get_progress, approval
|
|
- sdk_hooks audit + deny
|
|
- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve)
|
|
- audit.py new functions (log_tool_use, log_permission_decision)
|
|
- bot/commands.py new commands (//stop, //progress, //perm auto)
|
|
- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool)
|
|
- bot/handler.py text approval fallback
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_audit_dir(tmp_path):
|
|
"""Redirect audit logs to a temp directory."""
|
|
import agent.audit as audit_mod
|
|
original = audit_mod.AUDIT_DIR
|
|
audit_mod.AUDIT_DIR = tmp_path / "audit"
|
|
yield tmp_path / "audit"
|
|
audit_mod.AUDIT_DIR = original
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sdk_client():
|
|
"""Create a mock ClaudeSDKClient that yields controllable messages."""
|
|
client = AsyncMock()
|
|
client.connect = AsyncMock()
|
|
client.disconnect = AsyncMock()
|
|
client.query = AsyncMock()
|
|
client.interrupt = AsyncMock()
|
|
client.set_permission_mode = AsyncMock()
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_feishu():
|
|
"""Mock all Feishu send functions."""
|
|
captured = {"texts": [], "cards": [], "markdowns": []}
|
|
|
|
async def _send_text(rid, rtype, text):
|
|
captured["texts"].append(text)
|
|
|
|
async def _send_card(rid, rtype, card):
|
|
captured["cards"].append(card)
|
|
|
|
async def _send_markdown(rid, rtype, content):
|
|
captured["markdowns"].append(content)
|
|
|
|
with patch("bot.feishu.send_text", side_effect=_send_text), \
|
|
patch("bot.feishu.send_card", side_effect=_send_card), \
|
|
patch("bot.feishu.send_markdown", side_effect=_send_markdown), \
|
|
patch("bot.handler.send_text", side_effect=_send_text), \
|
|
patch("bot.handler.send_markdown", side_effect=_send_markdown):
|
|
yield captured
|
|
|
|
|
|
# ===========================================================================
|
|
# 1. SDKSession unit tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSDKSessionProgress:
|
|
"""Test SDKSession.get_progress() with buffered messages."""
|
|
|
|
def test_initial_progress_is_idle(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
p = s.get_progress()
|
|
assert p.busy is False
|
|
assert p.current_prompt == ""
|
|
assert p.text_messages == []
|
|
assert p.tool_calls == []
|
|
|
|
def test_progress_after_manual_state_change(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
s._busy = True
|
|
s._current_prompt = "write hello.py"
|
|
s._started_at = time.time() - 5
|
|
s._text_buffer = ["I'll create the file", "Done"]
|
|
s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"]
|
|
p = s.get_progress()
|
|
assert p.busy is True
|
|
assert p.current_prompt == "write hello.py"
|
|
assert p.elapsed_seconds >= 4
|
|
assert len(p.text_messages) == 2
|
|
assert len(p.tool_calls) == 2
|
|
|
|
def test_buffer_limits(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
for i in range(30):
|
|
s._text_buffer.append(f"text-{i}")
|
|
if len(s._text_buffer) > s.MAX_BUFFER_TEXTS:
|
|
s._text_buffer.pop(0)
|
|
assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS
|
|
assert s._text_buffer[0] == "text-10"
|
|
|
|
def test_progress_pending_approval(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
s._pending_approval_desc = "Bash: `rm -rf /tmp/test`"
|
|
p = s.get_progress()
|
|
assert p.pending_approval == "Bash: `rm -rf /tmp/test`"
|
|
|
|
|
|
class TestSDKSessionApproval:
|
|
"""Test the approval mechanism."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_resolves_future(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
loop = asyncio.get_running_loop()
|
|
s._pending_approval = loop.create_future()
|
|
await s.approve(True)
|
|
assert s._pending_approval.result() is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_deny(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
loop = asyncio.get_running_loop()
|
|
s._pending_approval = loop.create_future()
|
|
await s.approve(False)
|
|
assert s._pending_approval.result() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_no_pending_is_noop(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
# Should not raise
|
|
await s.approve(True)
|
|
|
|
|
|
class TestSDKSessionClose:
|
|
"""Test clean shutdown."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_without_start(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
# Should not raise
|
|
await s.close()
|
|
assert s.client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_disconnects_client(self, mock_sdk_client):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
s.client = mock_sdk_client
|
|
s._message_loop_task = None
|
|
await s.close()
|
|
mock_sdk_client.disconnect.assert_awaited_once()
|
|
assert s.client is None
|
|
|
|
|
|
class TestSDKSessionSend:
|
|
"""Test send() and send_and_wait() with mocked client."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_returns_immediately(self):
|
|
from agent.sdk_session import SDKSession
|
|
|
|
s = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
|
|
|
# Provide a mock client that has receive_messages yielding nothing
|
|
mock_client = AsyncMock()
|
|
mock_client.query = AsyncMock()
|
|
|
|
async def _empty_messages():
|
|
return
|
|
yield # make it an async generator
|
|
|
|
mock_client.receive_messages = _empty_messages
|
|
s.client = mock_client
|
|
|
|
result = await s.send("hello")
|
|
assert "已开始执行" in result
|
|
assert s._busy is True
|
|
assert s._current_prompt == "hello"
|
|
|
|
# Cleanup
|
|
if s._message_loop_task:
|
|
s._message_loop_task.cancel()
|
|
try:
|
|
await s._message_loop_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
class TestSDKSessionFormatSummary:
|
|
"""Test _format_tool_summary."""
|
|
|
|
def test_bash_summary(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
result = s._format_tool_summary("Bash", {"command": "ls -la"})
|
|
assert "`ls -la`" in result
|
|
|
|
def test_edit_summary(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"})
|
|
assert "test.py" in result
|
|
|
|
def test_other_summary_truncated(self):
|
|
from agent.sdk_session import SDKSession
|
|
s = SDKSession("c1", "/tmp", "u1")
|
|
result = s._format_tool_summary("CustomTool", {"key": "x" * 500})
|
|
assert len(result) <= 200
|
|
|
|
|
|
# ===========================================================================
|
|
# 2. sdk_hooks tests
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSDKHooks:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audit_hook_logs(self, tmp_audit_dir):
|
|
from agent.sdk_hooks import audit_hook
|
|
|
|
input_data = {
|
|
"session_id": "test-session",
|
|
"tool_name": "Bash",
|
|
"tool_input": {"command": "echo hello"},
|
|
"tool_response": "hello\n",
|
|
}
|
|
result = await audit_hook(input_data, "tu-1", {"signal": None})
|
|
assert result == {}
|
|
|
|
# Check JSONL was written
|
|
log_file = tmp_audit_dir / "test-session.jsonl"
|
|
assert log_file.exists()
|
|
entry = json.loads(log_file.read_text().strip())
|
|
assert entry["type"] == "tool_use"
|
|
assert entry["tool_name"] == "Bash"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deny_dangerous_rm_rf(self):
|
|
from agent.sdk_hooks import deny_dangerous_hook
|
|
|
|
input_data = {
|
|
"tool_name": "Bash",
|
|
"tool_input": {"command": "rm -rf /"},
|
|
}
|
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
|
assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deny_allows_safe_commands(self):
|
|
from agent.sdk_hooks import deny_dangerous_hook
|
|
|
|
input_data = {
|
|
"tool_name": "Bash",
|
|
"tool_input": {"command": "ls -la /tmp"},
|
|
}
|
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
|
assert result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deny_ignores_non_bash(self):
|
|
from agent.sdk_hooks import deny_dangerous_hook
|
|
|
|
input_data = {
|
|
"tool_name": "Edit",
|
|
"tool_input": {"file_path": "/etc/passwd"},
|
|
}
|
|
result = await deny_dangerous_hook(input_data, None, {"signal": None})
|
|
assert result == {}
|
|
|
|
def test_build_hooks_returns_expected_structure(self):
|
|
from agent.sdk_hooks import build_hooks
|
|
|
|
hooks = build_hooks("test-conv")
|
|
assert "PostToolUse" in hooks
|
|
assert "PreToolUse" in hooks
|
|
assert len(hooks["PostToolUse"]) == 1
|
|
assert len(hooks["PreToolUse"]) == 1
|
|
|
|
|
|
# ===========================================================================
|
|
# 3. manager tests (new methods)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSessionManagerNew:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(self):
|
|
from agent.manager import manager
|
|
manager._sessions.clear()
|
|
yield
|
|
manager._sessions.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_no_cc_timeout(self):
|
|
from agent.manager import manager, Session
|
|
s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1")
|
|
assert s.conv_id == "c1"
|
|
assert s.chat_id == "chat1"
|
|
assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_progress_no_session(self):
|
|
from agent.manager import manager
|
|
result = manager.get_progress("nonexistent")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_progress_no_sdk_session(self):
|
|
from agent.manager import manager
|
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
|
p = manager.get_progress("c1", user_id="u1")
|
|
assert p is not None
|
|
assert p.busy is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_no_sdk_session(self):
|
|
from agent.manager import manager
|
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
|
result = await manager.interrupt("c1", user_id="u1")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_no_sdk_session(self):
|
|
from agent.manager import manager
|
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
|
# Should not raise
|
|
await manager.approve("c1", True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_with_sdk_session(self):
|
|
from agent.manager import manager
|
|
from agent.sdk_session import SDKSession
|
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
|
mock_sdk = MagicMock(spec=SDKSession)
|
|
mock_sdk.close = AsyncMock()
|
|
manager._sessions["c1"].sdk_session = mock_sdk
|
|
|
|
result = await manager.close("c1", user_id="u1")
|
|
assert result is True
|
|
mock_sdk.close.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_permission_mode_with_sdk_session(self):
|
|
from agent.manager import manager
|
|
from agent.sdk_session import SDKSession
|
|
await manager.create("c1", "/tmp/test", owner_id="u1")
|
|
|
|
mock_sdk = MagicMock(spec=SDKSession)
|
|
mock_sdk.set_permission_mode = AsyncMock()
|
|
manager._sessions["c1"].sdk_session = mock_sdk
|
|
|
|
manager.set_permission_mode("c1", "acceptEdits", user_id="u1")
|
|
assert manager._sessions["c1"].permission_mode == "acceptEdits"
|
|
|
|
def test_list_sessions_includes_busy(self):
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
mock_sdk = MagicMock(spec=SDKSession)
|
|
mock_sdk._busy = True
|
|
session.sdk_session = mock_sdk
|
|
manager._sessions["c1"] = session
|
|
result = manager.list_sessions()
|
|
assert result[0]["busy"] is True
|
|
|
|
def test_session_from_dict_strips_old_fields(self):
|
|
from agent.manager import Session
|
|
old_data = {
|
|
"conv_id": "c1",
|
|
"cwd": "/tmp",
|
|
"owner_id": "u1",
|
|
"cc_session_id": "old-uuid",
|
|
"started": True,
|
|
"cc_timeout": 300.0,
|
|
"last_activity": 0.0,
|
|
"idle_timeout": 1800,
|
|
"permission_mode": "default",
|
|
}
|
|
s = Session.from_dict(old_data)
|
|
assert s.conv_id == "c1"
|
|
assert not hasattr(s, "cc_session_id")
|
|
|
|
def test_session_to_dict_excludes_sdk_session(self):
|
|
from agent.manager import Session
|
|
s = Session(conv_id="c1", cwd="/tmp")
|
|
s.sdk_session = MagicMock()
|
|
d = s.to_dict()
|
|
assert "sdk_session" not in d
|
|
|
|
|
|
# ===========================================================================
|
|
# 4. audit tests (new functions)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestAuditNewFunctions:
|
|
|
|
def test_log_tool_use(self, tmp_audit_dir):
|
|
from agent.audit import log_tool_use
|
|
|
|
log_tool_use(
|
|
session_id="s1",
|
|
tool_name="Bash",
|
|
tool_input={"command": "echo hello"},
|
|
tool_response="hello\n",
|
|
)
|
|
|
|
log_file = tmp_audit_dir / "s1.jsonl"
|
|
assert log_file.exists()
|
|
entry = json.loads(log_file.read_text().strip())
|
|
assert entry["type"] == "tool_use"
|
|
assert entry["tool_name"] == "Bash"
|
|
|
|
def test_log_permission_decision_approved(self, tmp_audit_dir):
|
|
from agent.audit import log_permission_decision
|
|
|
|
log_permission_decision(
|
|
conv_id="c1",
|
|
tool_name="Bash",
|
|
tool_input={"command": "rm test.txt"},
|
|
approved=True,
|
|
)
|
|
|
|
log_file = tmp_audit_dir / "c1.jsonl"
|
|
assert log_file.exists()
|
|
entry = json.loads(log_file.read_text().strip())
|
|
assert entry["type"] == "permission_decision"
|
|
assert entry["approved"] is True
|
|
|
|
def test_log_permission_decision_denied(self, tmp_audit_dir):
|
|
from agent.audit import log_permission_decision
|
|
|
|
log_permission_decision(
|
|
conv_id="c1",
|
|
tool_name="Write",
|
|
tool_input={"file_path": "/etc/passwd"},
|
|
approved=False,
|
|
)
|
|
|
|
log_file = tmp_audit_dir / "c1.jsonl"
|
|
entry = json.loads(log_file.read_text().strip())
|
|
assert entry["approved"] is False
|
|
|
|
|
|
# ===========================================================================
|
|
# 5. bot/commands.py new commands
|
|
# ===========================================================================
|
|
|
|
|
|
class TestNewCommands:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(self):
|
|
from agent.manager import manager
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user, set_current_chat
|
|
manager._sessions.clear()
|
|
agent._active_conv.clear()
|
|
agent._passthrough.clear()
|
|
set_current_user(None)
|
|
set_current_chat(None)
|
|
yield
|
|
manager._sessions.clear()
|
|
agent._active_conv.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_no_active_session(self):
|
|
from bot.commands import handle_command
|
|
result = await handle_command("u1", "//stop")
|
|
assert "No active session" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_session_no_sdk(self):
|
|
from bot.commands import handle_command
|
|
from agent.manager import manager, Session
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user
|
|
|
|
set_current_user("u1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
result = await handle_command("u1", "//stop")
|
|
assert "No active task" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_no_session(self):
|
|
from bot.commands import handle_command
|
|
result = await handle_command("u1", "//progress")
|
|
assert "No active session" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_idle_session(self):
|
|
from bot.commands import handle_command
|
|
from agent.manager import manager, Session
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user
|
|
|
|
set_current_user("u1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
result = await handle_command("u1", "//progress")
|
|
assert "空闲" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_busy_session(self):
|
|
from bot.commands import handle_command
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user
|
|
|
|
set_current_user("u1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
sdk = SDKSession("c1", "/tmp", "u1")
|
|
sdk._busy = True
|
|
sdk._started_at = time.time() - 10
|
|
sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"]
|
|
session.sdk_session = sdk
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
result = await handle_command("u1", "//progress")
|
|
assert "执行中" in result
|
|
assert "Bash" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_with_pending_approval(self):
|
|
from bot.commands import handle_command
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user
|
|
|
|
set_current_user("u1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
sdk = SDKSession("c1", "/tmp", "u1")
|
|
sdk._busy = True
|
|
sdk._started_at = time.time() - 5
|
|
sdk._pending_approval_desc = "Bash: `rm test`"
|
|
session.sdk_session = sdk
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
result = await handle_command("u1", "//progress")
|
|
assert "审批" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_perm_auto_alias(self):
|
|
from bot.commands import handle_command
|
|
from agent.manager import manager, Session
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_user
|
|
|
|
set_current_user("u1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
result = await handle_command("u1", "//perm auto")
|
|
assert "auto" in result
|
|
assert manager._sessions["c1"].permission_mode == "dontAsk"
|
|
|
|
|
|
# ===========================================================================
|
|
# 6. orchestrator/tools.py new tools
|
|
# ===========================================================================
|
|
|
|
|
|
class TestNewTools:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(self):
|
|
from agent.manager import manager
|
|
from orchestrator.tools import set_current_user, set_current_chat
|
|
manager._sessions.clear()
|
|
set_current_user("u1")
|
|
set_current_chat("chat1")
|
|
yield
|
|
manager._sessions.clear()
|
|
set_current_user(None)
|
|
set_current_chat(None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_progress_not_found(self):
|
|
from orchestrator.tools import SessionProgressTool
|
|
tool = SessionProgressTool()
|
|
result = await tool._arun("nonexistent")
|
|
data = json.loads(result)
|
|
assert "error" in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_progress_idle(self):
|
|
from orchestrator.tools import SessionProgressTool
|
|
from agent.manager import manager, Session
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
|
|
tool = SessionProgressTool()
|
|
result = await tool._arun("c1")
|
|
data = json.loads(result)
|
|
assert data["busy"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_progress_busy(self):
|
|
from orchestrator.tools import SessionProgressTool
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
sdk = SDKSession("c1", "/tmp", "u1")
|
|
sdk._busy = True
|
|
sdk._started_at = time.time() - 30
|
|
sdk._current_prompt = "fix the bug"
|
|
sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"]
|
|
session.sdk_session = sdk
|
|
manager._sessions["c1"] = session
|
|
|
|
tool = SessionProgressTool()
|
|
result = await tool._arun("c1")
|
|
data = json.loads(result)
|
|
assert data["busy"] is True
|
|
assert data["elapsed_seconds"] >= 29
|
|
assert "Edit" in str(data["recent_tools"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_not_found(self):
|
|
from orchestrator.tools import InterruptConversationTool
|
|
tool = InterruptConversationTool()
|
|
result = await tool._arun("nonexistent")
|
|
assert "not found" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupt_no_active_task(self):
|
|
from orchestrator.tools import InterruptConversationTool
|
|
from agent.manager import manager, Session
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
|
|
tool = InterruptConversationTool()
|
|
result = await tool._arun("c1")
|
|
assert "No active task" in result
|
|
|
|
|
|
# ===========================================================================
|
|
# 7. bot/handler.py text approval fallback
|
|
# ===========================================================================
|
|
|
|
|
|
class TestTextApprovalFallback:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset(self):
|
|
from agent.manager import manager
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_chat
|
|
manager._sessions.clear()
|
|
agent._active_conv.clear()
|
|
set_current_chat(None)
|
|
yield
|
|
manager._sessions.clear()
|
|
agent._active_conv.clear()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_y_resolves_pending_approval(self, mock_feishu):
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_chat
|
|
|
|
set_current_chat("chat1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
|
loop = asyncio.get_running_loop()
|
|
sdk._pending_approval = loop.create_future()
|
|
session.sdk_session = sdk
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
from bot.handler import _process_message
|
|
await _process_message("u1", "chat1", "y")
|
|
|
|
assert sdk._pending_approval.done()
|
|
assert sdk._pending_approval.result() is True
|
|
# handler calls send_text which is mocked separately from send_markdown
|
|
assert any("批准" in t for t in mock_feishu["texts"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_n_denies_pending_approval(self, mock_feishu):
|
|
from agent.manager import manager, Session
|
|
from agent.sdk_session import SDKSession
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_chat
|
|
|
|
set_current_chat("chat1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
|
|
loop = asyncio.get_running_loop()
|
|
sdk._pending_approval = loop.create_future()
|
|
session.sdk_session = sdk
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
from bot.handler import _process_message
|
|
await _process_message("u1", "chat1", "n")
|
|
|
|
assert sdk._pending_approval.done()
|
|
assert sdk._pending_approval.result() is False
|
|
assert any("拒绝" in t for t in mock_feishu["texts"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_y_without_pending_falls_through(self, mock_feishu):
|
|
"""If there's no pending approval, 'y' should not be consumed."""
|
|
from agent.manager import manager, Session
|
|
from orchestrator.agent import agent
|
|
from orchestrator.tools import set_current_chat
|
|
|
|
set_current_chat("chat1")
|
|
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
|
|
manager._sessions["c1"] = session
|
|
agent._active_conv["u1"] = "c1"
|
|
|
|
from bot.handler import _process_message
|
|
# Patch agent.run to avoid actual LLM call
|
|
with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"):
|
|
await _process_message("u1", "chat1", "y")
|
|
|
|
# Should not have consumed as approval
|
|
assert not any("批准" in t for t in mock_feishu["markdowns"])
|
|
|
|
|
|
# ===========================================================================
|
|
# 8. bot/feishu.py build_approval_card
|
|
# ===========================================================================
|
|
|
|
|
|
class TestBuildApprovalCard:
|
|
|
|
def test_card_structure(self):
|
|
from bot.feishu import build_approval_card
|
|
card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60)
|
|
|
|
assert card["schema"] == "2.0"
|
|
assert "权限审批" in card["header"]["title"]["content"]
|
|
|
|
body_elements = card["body"]["elements"]
|
|
tags = [e["tag"] for e in body_elements]
|
|
# JSON 2.0: no "action" wrapper, no "note" — buttons are direct elements
|
|
assert "action" not in tags
|
|
assert "note" not in tags
|
|
assert "markdown" in tags
|
|
assert tags.count("button") == 2
|
|
|
|
# Buttons carry approve/deny values
|
|
buttons = [e for e in body_elements if e["tag"] == "button"]
|
|
assert buttons[0]["value"]["action"] == "approve"
|
|
assert buttons[0]["value"]["conv_id"] == "c1"
|
|
assert buttons[1]["value"]["action"] == "deny"
|
|
|
|
|
|
# ===========================================================================
|
|
# 8b. bot/handler.py card callback response
|
|
# ===========================================================================
|
|
|
|
|
|
class TestCardCallbackResponse:
|
|
"""Test _handle_card_action returns proper callback response per
|
|
docs/feishu/card_callback_communication.md."""
|
|
|
|
def _make_card_event(self, action: str, conv_id: str) -> object:
|
|
"""Create a mock CustomizedEvent with card action payload."""
|
|
import lark_oapi as lark
|
|
mock_event = MagicMock()
|
|
payload = json.dumps({
|
|
"schema": "2.0",
|
|
"header": {
|
|
"event_id": "evt_123",
|
|
"event_type": "card.action.trigger",
|
|
},
|
|
"event": {
|
|
"operator": {
|
|
"open_id": "ou_test_user_123",
|
|
},
|
|
"action": {
|
|
"value": {"action": action, "conv_id": conv_id},
|
|
"tag": "button",
|
|
},
|
|
},
|
|
})
|
|
# lark.JSON.marshal returns the JSON string
|
|
with patch("lark_oapi.JSON.marshal", return_value=payload):
|
|
yield payload
|
|
|
|
def test_approve_returns_toast_and_card(self):
|
|
from bot.handler import _handle_card_action
|
|
payload = json.dumps({
|
|
"event": {
|
|
"operator": {"open_id": "ou_test"},
|
|
"action": {"value": {"action": "approve", "conv_id": "c1"}, "tag": "button"},
|
|
},
|
|
})
|
|
with patch("lark_oapi.JSON.marshal", return_value=payload), \
|
|
patch("bot.handler._main_loop", new=MagicMock()):
|
|
result = _handle_card_action(MagicMock())
|
|
|
|
assert result is not None
|
|
# Toast
|
|
assert result["toast"]["type"] == "success"
|
|
assert "批准" in result["toast"]["content"]
|
|
# Updated card
|
|
assert result["card"]["type"] == "raw"
|
|
card_data = result["card"]["data"]
|
|
assert card_data["schema"] == "2.0"
|
|
assert card_data["header"]["template"] == "green"
|
|
assert "批准" in card_data["body"]["elements"][0]["content"]
|
|
|
|
def test_deny_returns_warning_toast(self):
|
|
from bot.handler import _handle_card_action
|
|
payload = json.dumps({
|
|
"event": {
|
|
"operator": {"open_id": "ou_test"},
|
|
"action": {"value": {"action": "deny", "conv_id": "c1"}, "tag": "button"},
|
|
},
|
|
})
|
|
with patch("lark_oapi.JSON.marshal", return_value=payload), \
|
|
patch("bot.handler._main_loop", new=MagicMock()):
|
|
result = _handle_card_action(MagicMock())
|
|
|
|
assert result is not None
|
|
assert result["toast"]["type"] == "warning"
|
|
assert "拒绝" in result["toast"]["content"]
|
|
assert result["card"]["data"]["header"]["template"] == "red"
|
|
|
|
def test_missing_value_returns_none(self):
|
|
from bot.handler import _handle_card_action
|
|
payload = json.dumps({
|
|
"event": {
|
|
"action": {"value": {}, "tag": "button"},
|
|
},
|
|
})
|
|
with patch("lark_oapi.JSON.marshal", return_value=payload):
|
|
result = _handle_card_action(MagicMock())
|
|
assert result is None
|
|
|
|
|
|
# ===========================================================================
|
|
# 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
|