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:
+14
-77
@@ -1,61 +1,43 @@
|
||||
"""
|
||||
Master test fixtures for PhoneWork BDD tests.
|
||||
Shared test fixtures for PhoneWork tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
TESTS_DIR = Path(__file__).parent
|
||||
CASSETTES_DIR = TESTS_DIR / "cassettes"
|
||||
CASSETTES_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ── Feishu send mock ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def feishu_calls():
|
||||
"""
|
||||
Capture all calls to bot.feishu send functions.
|
||||
Lazy imports inside commands.py pull from bot.feishu at call time,
|
||||
so patching the module attributes is sufficient.
|
||||
"""
|
||||
captured: dict[str, list] = {"texts": [], "cards": [], "files": []}
|
||||
"""Capture all calls to bot.feishu send functions."""
|
||||
captured: dict[str, list] = {"texts": [], "cards": [], "markdowns": [], "files": []}
|
||||
|
||||
async def mock_send_text(receive_id, receive_id_type, text):
|
||||
captured["texts"].append({"receive_id": receive_id, "text": text})
|
||||
captured["texts"].append(text)
|
||||
|
||||
async def mock_send_markdown(receive_id, receive_id_type, content):
|
||||
captured["markdowns"].append(content)
|
||||
|
||||
async def mock_send_card(receive_id, receive_id_type, card):
|
||||
captured["cards"].append({"receive_id": receive_id, "card": card})
|
||||
captured["cards"].append(card)
|
||||
|
||||
async def mock_send_file(receive_id, receive_id_type, file_path, file_type="stream"):
|
||||
captured["files"].append({"receive_id": receive_id, "file_path": file_path})
|
||||
captured["files"].append(file_path)
|
||||
|
||||
with patch("bot.feishu.send_text", side_effect=mock_send_text), \
|
||||
patch("bot.feishu.send_markdown", side_effect=mock_send_markdown), \
|
||||
patch("bot.feishu.send_card", side_effect=mock_send_card), \
|
||||
patch("bot.feishu.send_file", side_effect=mock_send_file):
|
||||
patch("bot.feishu.send_file", side_effect=mock_send_file), \
|
||||
patch("bot.handler.send_text", side_effect=mock_send_text), \
|
||||
patch("bot.handler.send_markdown", side_effect=mock_send_markdown):
|
||||
yield captured
|
||||
|
||||
|
||||
# ── run_claude mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_claude():
|
||||
"""
|
||||
Replace run_claude in both its definition and its import site in manager.py.
|
||||
Default return value is a short CC-style output string.
|
||||
"""
|
||||
mock = AsyncMock(return_value="Claude Code: task complete.")
|
||||
with patch("agent.cc_runner.run_claude", mock), \
|
||||
patch("agent.manager.run_claude", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
# ── Singleton state resets ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -110,13 +92,6 @@ def reset_contextvars():
|
||||
set_current_chat(None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_reply(pytestconfig):
|
||||
"""Clear _reply before each test so stale values don't leak between scenarios."""
|
||||
pytestconfig._reply = None
|
||||
yield
|
||||
|
||||
|
||||
# ── Working directory isolation ──────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
@@ -127,41 +102,3 @@ def tmp_working_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tools_mod, "WORKING_DIR", tmp_path)
|
||||
(tmp_path / "myproject").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ── VCR cassette factory ─────────────────────────────────────────────────────
|
||||
|
||||
def make_vcr_cassette(cassette_name: str):
|
||||
"""
|
||||
Return a vcrpy context manager for the given cassette name.
|
||||
Set VCR_RECORD_MODE=new_episodes locally to record; CI uses 'none'.
|
||||
Authorization headers are stripped so cassettes are safe to commit.
|
||||
If the cassette doesn't exist in 'none' mode, the test is skipped.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
import vcr
|
||||
except ImportError:
|
||||
import pytest
|
||||
pytest.skip("vcrpy not installed")
|
||||
|
||||
record_mode = os.environ.get("VCR_RECORD_MODE", "none")
|
||||
cassette_path = CASSETTES_DIR / cassette_name
|
||||
cassette_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if record_mode == "none" and not cassette_path.exists():
|
||||
import pytest
|
||||
pytest.skip(f"No cassette recorded yet: {cassette_name}. Run with VCR_RECORD_MODE=new_episodes to record.")
|
||||
|
||||
my_vcr = vcr.VCR(
|
||||
record_mode=record_mode,
|
||||
match_on=["method", "scheme", "host", "port", "path", "body"],
|
||||
filter_headers=["authorization", "x-api-key"],
|
||||
decode_compressed_response=True,
|
||||
)
|
||||
return my_vcr.use_cassette(str(cassette_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vcr_cassette():
|
||||
return make_vcr_cassette
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user