feat(03-03): implement Orchestrator class

- Add Orchestrator class with handle_message() and handle_callback() entry points
- Command parsing via regex: /gsd:* pattern extraction
- Auto-session-creation per CONTEXT.md decision
- Error handling with try/except returning format_error() cards
- Approval detection integration with ApprovalHandler
- Output truncation to 2000 chars for Feishu card limits
- 18 tests passing with full coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:20:24 +08:00
parent 086be891b9
commit 6d9bd902ea
2 changed files with 250 additions and 0 deletions
@@ -0,0 +1,225 @@
"""
Main command router and orchestrator for OpenClaw skill invocation.
This module provides the central command routing system that:
- Receives chat messages via handle_message()
- Routes commands through session management and command execution
- Handles approval workflows and progress updates
- Returns formatted Feishu card responses
"""
from __future__ import annotations
import re
from typing import Any, Dict, Optional, Tuple
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager
from gsd_openclaw.command_executor import CommandExecutor, CommandResult
from gsd_openclaw.approval_handler import ApprovalHandler
from gsd_openclaw.progress_monitor import ProgressMonitor
from gsd_openclaw.response_formatter import FeishuResponseFormatter
class Orchestrator:
"""Central command router for OpenClaw skill invocation.
This class provides the main entry point for handling GSD commands
from chat messages, integrating session management, command execution,
approval handling, and response formatting.
Attributes:
tmux_manager: TmuxSessionManager for session lifecycle.
formatter: FeishuResponseFormatter for card creation.
executor: CommandExecutor for running commands.
approval_handler: ApprovalHandler for interactive approvals.
progress_monitor: ProgressMonitor for progress updates.
"""
# Regex pattern for /gsd:* command parsing
COMMAND_PATTERN = re.compile(r'^/gsd:(\S+)\s*(.*)$')
# Maximum characters for card content (Feishu limit)
MAX_OUTPUT_LENGTH = 2000
def __init__(
self,
tmux_manager: Optional[TmuxSessionManager] = None,
formatter: Optional[FeishuResponseFormatter] = None
):
"""Initialize Orchestrator.
Args:
tmux_manager: Optional TmuxSessionManager instance.
If not provided, a new one will be created.
formatter: Optional FeishuResponseFormatter instance.
If not provided, a new one will be created.
"""
self.tmux_manager = tmux_manager or TmuxSessionManager()
self.formatter = formatter or FeishuResponseFormatter()
self.executor = CommandExecutor()
self.approval_handler = ApprovalHandler(self.formatter)
self.progress_monitor = ProgressMonitor(self.formatter)
def parse_command(self, message: str) -> Optional[Tuple[str, str]]:
"""Parse a message for /gsd:* command pattern.
Args:
message: The message text to parse.
Returns:
Tuple of (command, args) if matched, None otherwise.
"""
match = self.COMMAND_PATTERN.match(message.strip())
if match:
command = match.group(1)
args = match.group(2).strip()
return (command, args)
return None
def handle_message(
self,
user_id: str,
message: str,
channel: str,
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Handle an incoming chat message.
This is the main entry point for processing GSD commands.
It handles:
1. Command parsing
2. Session management (auto-create if needed)
3. Command execution
4. Approval detection
5. Response formatting
Args:
user_id: ID of the user sending the message.
message: The message text.
channel: The channel where the message was sent.
context: Additional context including project name.
Returns:
Feishu Card JSON dict as response.
"""
try:
# Parse command
parsed = self.parse_command(message)
if parsed is None:
return self.formatter.format_info(
title="无效命令",
content="请使用 /gsd:* 格式的命令。例如: /gsd:status"
)
command, args = parsed
# Get project name from context
project_name = context.get("project", "default")
# Get or create session (auto-create per CONTEXT.md)
session = self.tmux_manager.get_session(project_name)
if session is None:
session = self.tmux_manager.create_session(project_name)
# Build the full command
full_command = f"/gsd:{command}"
if args:
full_command = f"{full_command} {args}"
# Execute command
result = self.executor.execute(session, full_command)
# Truncate output if needed
output = result.output
if len(output) > self.MAX_OUTPUT_LENGTH:
output = output[-self.MAX_OUTPUT_LENGTH:]
# Check for approval request
approval_context = self.approval_handler.detect_approval_request(result.output)
if approval_context:
return self.approval_handler.create_approval_card(
context=approval_context,
session_id=session.session_id,
project_name=project_name
)
# Return appropriate response based on result
if result.has_error:
return self.formatter.format_error(
title="命令执行失败",
content=output
)
else:
return self.formatter.format_success(
title="执行成功",
content=output
)
except Exception as e:
return self.formatter.format_error(
title="命令执行失败",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
def handle_callback(self, callback_data: Dict[str, Any]) -> Dict[str, Any]:
"""Handle a button callback from an approval card.
This processes approval/reject actions and sends the
response to the tmux session.
Args:
callback_data: Dictionary containing:
- action: "approve" or "reject"
- approval_id: The ID of the approval
- project: The project name
Returns:
Feishu Card JSON dict as confirmation response.
"""
try:
action = callback_data.get("action", "")
approval_id = callback_data.get("approval_id", "")
project_name = callback_data.get("project", "default")
# Handle callback via approval handler
result = self.approval_handler.handle_callback(callback_data)
if result.startswith("error:"):
return self.formatter.format_error(
title="操作失败",
content=result
)
# Get session
session = self.tmux_manager.get_session(project_name)
if session is None:
return self.formatter.format_error(
title="会话不存在",
content="无法找到对应的会话"
)
# Send response to tmux
if result == "approved":
response_text = "y" # Standard confirmation
self.executor.send_command(session.tmux_session.active_pane, response_text)
return self.formatter.format_success(
title="已确认",
content="操作已继续执行"
)
elif result == "rejected":
response_text = "n" # Standard rejection
self.executor.send_command(session.tmux_session.active_pane, response_text)
return self.formatter.format_info(
title="已取消",
content="操作已取消"
)
else:
return self.formatter.format_error(
title="未知结果",
content=f"处理结果: {result}"
)
except Exception as e:
return self.formatter.format_error(
title="操作失败",
content=str(e)[-self.MAX_OUTPUT_LENGTH:]
)
+25
View File
@@ -116,6 +116,8 @@ class TestHandleMessage:
"""handle_message should execute command and return success card."""
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
mock_orchestrator.executor.execute.return_value = CommandResult(
@@ -125,6 +127,8 @@ class TestHandleMessage:
error_type=None
)
# Ensure no approval is detected
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
mock_orchestrator.formatter.format_success.return_value = {"card": "success"}
result = mock_orchestrator.handle_message(
@@ -142,6 +146,9 @@ class TestHandleMessage:
def test_handle_message_returns_error_card_when_command_fails(self, mock_orchestrator):
"""handle_message should return error card when command fails."""
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
mock_orchestrator.executor.execute.return_value = CommandResult(
@@ -151,6 +158,8 @@ class TestHandleMessage:
error_type="error"
)
# Ensure no approval is detected
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
mock_orchestrator.formatter.format_error.return_value = {"card": "error"}
result = mock_orchestrator.handle_message(
@@ -166,6 +175,7 @@ class TestHandleMessage:
def test_handle_message_returns_approval_card_when_approval_detected(self, mock_orchestrator):
"""handle_message should return approval card when approval is detected."""
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
mock_orchestrator.executor.execute.return_value = CommandResult(
@@ -196,6 +206,9 @@ class TestHandleMessage:
def test_handle_message_truncates_output_to_2000_chars(self, mock_orchestrator):
"""handle_message should truncate output to 2000 chars for card limits."""
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
mock_orchestrator.tmux_manager.get_session.return_value = mock_tmux_session
# Create long output
@@ -207,6 +220,8 @@ class TestHandleMessage:
error_type=None
)
# Ensure no approval is detected
mock_orchestrator.approval_handler.detect_approval_request.return_value = None
mock_orchestrator.formatter.format_success.return_value = {"card": "success"}
result = mock_orchestrator.handle_message(
@@ -384,6 +399,9 @@ class TestIntegrationFlows:
orch = orchestrator_with_real_components
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
orch.tmux_manager.get_session.return_value = mock_tmux_session
orch.executor.execute.return_value = CommandResult(
@@ -409,6 +427,7 @@ class TestIntegrationFlows:
orch = orchestrator_with_real_components
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
orch.tmux_manager.get_session.return_value = mock_tmux_session
orch.executor.execute.return_value = CommandResult(
@@ -437,6 +456,9 @@ class TestIntegrationFlows:
orch = orchestrator_with_real_components
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
orch.tmux_manager.get_session.return_value = mock_tmux_session
# First create an approval via handle_message
@@ -482,6 +504,9 @@ class TestIntegrationFlows:
orch = orchestrator_with_real_components
mock_tmux_session = MagicMock(spec=TmuxSession)
mock_tmux_session.session_id = "test-123"
mock_tmux_session.tmux_session = MagicMock()
mock_tmux_session.tmux_session.active_pane = MagicMock()
orch.tmux_manager.get_session.return_value = mock_tmux_session
# First create an approval via handle_message