Merge pull request #456 from multica-ai/agent/j/25583cc6
feat(agent): add OpenClaw runtime support
This commit is contained in:
commit
f16b36fbc8
5 changed files with 906 additions and 9 deletions
|
|
@ -30,7 +30,7 @@ type Config struct {
|
|||
RuntimeName string
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry
|
||||
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry, "opencode" -> entry, "openclaw" -> entry
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
|
|
@ -92,8 +92,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
|||
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCODE_MODEL")),
|
||||
}
|
||||
}
|
||||
openclawPath := envOrDefault("MULTICA_OPENCLAW_PATH", "openclaw")
|
||||
if _, err := exec.LookPath(openclawPath); err == nil {
|
||||
agents["openclaw"] = AgentEntry{
|
||||
Path: openclawPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_OPENCLAW_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, or opencode and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, or openclaw and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ import (
|
|||
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
|
||||
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
|
||||
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .config/opencode/skills/)
|
||||
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "opencode":
|
||||
case "codex", "opencode", "openclaw":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
|
|
@ -122,8 +123,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
|||
case "claude":
|
||||
// Claude discovers skills natively from .claude/skills/ — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "codex", "opencode":
|
||||
// Codex and OpenCode discover skills natively from their respective paths — just list names.
|
||||
case "codex", "opencode", "openclaw":
|
||||
// Codex, OpenCode, and OpenClaw discover skills natively from their respective paths — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
default:
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Package agent provides a unified interface for executing prompts via
|
||||
// coding agents (Claude Code, Codex, OpenCode). It mirrors the happy-cli AgentBackend
|
||||
// coding agents (Claude Code, Codex, OpenCode, OpenClaw). It mirrors the happy-cli AgentBackend
|
||||
// pattern, translated to idiomatic Go.
|
||||
package agent
|
||||
|
||||
|
|
@ -73,13 +73,13 @@ type Result struct {
|
|||
|
||||
// Config configures a Backend instance.
|
||||
type Config struct {
|
||||
ExecutablePath string // path to CLI binary (claude, codex, or opencode)
|
||||
ExecutablePath string // path to CLI binary (claude, codex, opencode, or openclaw)
|
||||
Env map[string]string // extra environment variables
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a Backend for the given agent type.
|
||||
// Supported types: "claude", "codex", "opencode".
|
||||
// Supported types: "claude", "codex", "opencode", "openclaw".
|
||||
func New(agentType string, cfg Config) (Backend, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
|
|
@ -92,8 +92,10 @@ func New(agentType string, cfg Config) (Backend, error) {
|
|||
return &codexBackend{cfg: cfg}, nil
|
||||
case "opencode":
|
||||
return &opencodeBackend{cfg: cfg}, nil
|
||||
case "openclaw":
|
||||
return &openclawBackend{cfg: cfg}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode)", agentType)
|
||||
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw)", agentType)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
313
server/pkg/agent/openclaw.go
Normal file
313
server/pkg/agent/openclaw.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openclawBackend implements Backend by spawning `openclaw agent -p <prompt>
|
||||
// --output-format stream-json --yes` and reading streaming NDJSON events from
|
||||
// stdout — similar to the opencode backend.
|
||||
type openclawBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "openclaw"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("openclaw executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := []string{"agent", "--output-format", "stream-json", "--yes"}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--system-prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
||||
}
|
||||
if opts.ResumeSessionID != "" {
|
||||
args = append(args, "--session", opts.ResumeSessionID)
|
||||
}
|
||||
args = append(args, "-p", prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
cmd.Env = buildEnv(b.cfg.Env)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("openclaw stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[openclaw:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start openclaw: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("openclaw started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
|
||||
startTime := time.Now()
|
||||
scanResult := b.processEvents(stdout, msgCh)
|
||||
|
||||
// Wait for process exit.
|
||||
exitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
scanResult.status = "timeout"
|
||||
scanResult.errMsg = fmt.Sprintf("openclaw timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
scanResult.status = "aborted"
|
||||
scanResult.errMsg = "execution cancelled"
|
||||
} else if exitErr != nil && scanResult.status == "completed" {
|
||||
scanResult.status = "failed"
|
||||
scanResult.errMsg = fmt.Sprintf("openclaw exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
resCh <- Result{
|
||||
Status: scanResult.status,
|
||||
Output: scanResult.output,
|
||||
Error: scanResult.errMsg,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: scanResult.sessionID,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// ── Event handlers ──
|
||||
|
||||
// openclawEventResult holds accumulated state from processing the event stream.
|
||||
type openclawEventResult struct {
|
||||
status string
|
||||
errMsg string
|
||||
output string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// processEvents reads NDJSON lines from r, dispatches events to ch, and returns
|
||||
// the accumulated result.
|
||||
func (b *openclawBackend) processEvents(r io.Reader, ch chan<- Message) openclawEventResult {
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event openclawEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.SessionID != "" {
|
||||
sessionID = event.SessionID
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "text":
|
||||
b.handleOCTextEvent(event, ch, &output)
|
||||
case "thinking":
|
||||
b.handleOCThinkingEvent(event, ch)
|
||||
case "tool_call":
|
||||
b.handleOCToolCallEvent(event, ch)
|
||||
case "error":
|
||||
// NOTE: error events unconditionally set finalStatus to "failed" and
|
||||
// it stays sticky — subsequent text or result events won't revert it.
|
||||
// This is intentional: once an error fires, the session is considered
|
||||
// failed regardless of later events.
|
||||
b.handleOCErrorEvent(event, ch, &finalStatus, &finalError)
|
||||
case "step_start":
|
||||
trySend(ch, Message{Type: MessageStatus, Status: "running"})
|
||||
case "step_end":
|
||||
// Captures final session ID from step_end if present.
|
||||
case "result":
|
||||
// The result event only updates status on explicit failure. A
|
||||
// "completed" result is a no-op because finalStatus defaults to
|
||||
// "completed". Any unrecognized status (e.g. "partial") is also
|
||||
// treated as success — update this if OpenClaw adds new statuses.
|
||||
if event.Data != nil {
|
||||
if s, ok := event.Data["status"].(string); ok && s != "" {
|
||||
if s == "error" || s == "failed" {
|
||||
finalStatus = "failed"
|
||||
if msg, ok := event.Data["error"].(string); ok {
|
||||
finalError = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for scanner errors (e.g. broken pipe, read errors).
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
b.cfg.Logger.Warn("openclaw stdout scanner error", "error", scanErr)
|
||||
if finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
return openclawEventResult{
|
||||
status: finalStatus,
|
||||
errMsg: finalError,
|
||||
output: output.String(),
|
||||
sessionID: sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *openclawBackend) handleOCTextEvent(event openclawEvent, ch chan<- Message, output *strings.Builder) {
|
||||
text := openclawExtractText(event.Data)
|
||||
if text != "" {
|
||||
output.WriteString(text)
|
||||
trySend(ch, Message{Type: MessageText, Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *openclawBackend) handleOCThinkingEvent(event openclawEvent, ch chan<- Message) {
|
||||
text := openclawExtractText(event.Data)
|
||||
if text != "" {
|
||||
trySend(ch, Message{Type: MessageThinking, Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
// handleOCToolCallEvent processes "tool_call" events from OpenClaw. A single
|
||||
// tool_call event may contain both the call and result when the tool has
|
||||
// completed (status == "completed").
|
||||
func (b *openclawBackend) handleOCToolCallEvent(event openclawEvent, ch chan<- Message) {
|
||||
if event.Data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name, _ := event.Data["name"].(string)
|
||||
callID, _ := event.Data["callId"].(string)
|
||||
|
||||
// Extract input.
|
||||
var input map[string]any
|
||||
if raw, ok := event.Data["input"]; ok {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
input = m
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the tool-use message.
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: name,
|
||||
CallID: callID,
|
||||
Input: input,
|
||||
})
|
||||
|
||||
// If the tool has completed, also emit a tool-result message.
|
||||
status, _ := event.Data["status"].(string)
|
||||
if status == "completed" {
|
||||
outputStr := extractToolOutput(event.Data["output"])
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: name,
|
||||
CallID: callID,
|
||||
Output: outputStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *openclawBackend) handleOCErrorEvent(event openclawEvent, ch chan<- Message, finalStatus, finalError *string) {
|
||||
errMsg := ""
|
||||
if event.Data != nil {
|
||||
if msg, ok := event.Data["message"].(string); ok {
|
||||
errMsg = msg
|
||||
}
|
||||
if errMsg == "" {
|
||||
if code, ok := event.Data["code"].(string); ok {
|
||||
errMsg = code
|
||||
}
|
||||
}
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown openclaw error"
|
||||
}
|
||||
|
||||
b.cfg.Logger.Warn("openclaw error event", "error", errMsg)
|
||||
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
||||
|
||||
*finalStatus = "failed"
|
||||
*finalError = errMsg
|
||||
}
|
||||
|
||||
// openclawExtractText extracts text content from an openclaw event data map.
|
||||
// Supports both flat {"text": "..."} and nested {"content": {"text": "..."}} layouts.
|
||||
func openclawExtractText(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
// Try "text" field directly.
|
||||
if text, ok := data["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
// Try nested "content.text".
|
||||
if content, ok := data["content"].(map[string]any); ok {
|
||||
if text, ok := content["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── JSON types for `openclaw agent --output-format stream-json` stdout events ──
|
||||
|
||||
// openclawEvent represents a single NDJSON line from OpenClaw's stream-json output.
|
||||
//
|
||||
// Event types:
|
||||
//
|
||||
// "step_start" — agent step begins
|
||||
// "text" — text output from agent
|
||||
// "thinking" — model reasoning/thinking
|
||||
// "tool_call" — tool invocation with call and result
|
||||
// "error" — error from openclaw
|
||||
// "step_end" — agent step completes
|
||||
// "result" — final result with status
|
||||
type openclawEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
574
server/pkg/agent/openclaw_test.go
Normal file
574
server/pkg/agent/openclaw_test.go
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReturnsOpenclawBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New("openclaw", Config{ExecutablePath: "/nonexistent/openclaw"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(openclaw) error: %v", err)
|
||||
}
|
||||
if _, ok := b.(*openclawBackend); !ok {
|
||||
t.Fatalf("expected *openclawBackend, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text event tests ──
|
||||
|
||||
func TestOpenclawHandleTextEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "text",
|
||||
SessionID: "ses_abc",
|
||||
Data: map[string]any{"text": "Hello from openclaw"},
|
||||
}
|
||||
|
||||
b.handleOCTextEvent(event, ch, &output)
|
||||
|
||||
if output.String() != "Hello from openclaw" {
|
||||
t.Errorf("output: got %q, want %q", output.String(), "Hello from openclaw")
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageText {
|
||||
t.Errorf("type: got %v, want MessageText", msg.Type)
|
||||
}
|
||||
if msg.Content != "Hello from openclaw" {
|
||||
t.Errorf("content: got %q, want %q", msg.Content, "Hello from openclaw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleTextEventEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "text",
|
||||
Data: map[string]any{"text": ""},
|
||||
}
|
||||
|
||||
b.handleOCTextEvent(event, ch, &output)
|
||||
|
||||
if output.String() != "" {
|
||||
t.Errorf("expected empty output, got %q", output.String())
|
||||
}
|
||||
if len(ch) != 0 {
|
||||
t.Errorf("expected no messages, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleTextEventNilData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
var output strings.Builder
|
||||
|
||||
event := openclawEvent{Type: "text"}
|
||||
|
||||
b.handleOCTextEvent(event, ch, &output)
|
||||
|
||||
if output.String() != "" {
|
||||
t.Errorf("expected empty output, got %q", output.String())
|
||||
}
|
||||
if len(ch) != 0 {
|
||||
t.Errorf("expected no messages, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thinking event tests ──
|
||||
|
||||
func TestOpenclawHandleThinkingEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "thinking",
|
||||
Data: map[string]any{"text": "Let me think about this..."},
|
||||
}
|
||||
|
||||
b.handleOCThinkingEvent(event, ch)
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(ch))
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageThinking {
|
||||
t.Errorf("type: got %v, want MessageThinking", msg.Type)
|
||||
}
|
||||
if msg.Content != "Let me think about this..." {
|
||||
t.Errorf("content: got %q", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool call event tests ──
|
||||
|
||||
func TestOpenclawHandleToolCallCompleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "tool_call",
|
||||
Data: map[string]any{
|
||||
"name": "bash",
|
||||
"callId": "call_123",
|
||||
"input": map[string]any{"command": "pwd"},
|
||||
"status": "completed",
|
||||
"output": "/tmp/project\n",
|
||||
},
|
||||
}
|
||||
|
||||
b.handleOCToolCallEvent(event, ch)
|
||||
|
||||
// Should emit both tool-use and tool-result.
|
||||
if len(ch) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(ch))
|
||||
}
|
||||
|
||||
// First: tool-use
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolUse {
|
||||
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
||||
}
|
||||
if msg.Tool != "bash" {
|
||||
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
|
||||
}
|
||||
if msg.CallID != "call_123" {
|
||||
t.Errorf("callID: got %q, want %q", msg.CallID, "call_123")
|
||||
}
|
||||
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
|
||||
t.Errorf("input.command: got %v", msg.Input["command"])
|
||||
}
|
||||
|
||||
// Second: tool-result
|
||||
msg = <-ch
|
||||
if msg.Type != MessageToolResult {
|
||||
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
|
||||
}
|
||||
if msg.Output != "/tmp/project\n" {
|
||||
t.Errorf("output: got %q", msg.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleToolCallPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "tool_call",
|
||||
Data: map[string]any{
|
||||
"name": "read",
|
||||
"callId": "call_456",
|
||||
"input": map[string]any{"filePath": "/tmp/test.go"},
|
||||
"status": "pending",
|
||||
},
|
||||
}
|
||||
|
||||
b.handleOCToolCallEvent(event, ch)
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageToolUse {
|
||||
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleToolCallNilData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
event := openclawEvent{Type: "tool_call"}
|
||||
|
||||
b.handleOCToolCallEvent(event, ch)
|
||||
|
||||
if len(ch) != 0 {
|
||||
t.Errorf("expected no messages for nil data, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error event tests ──
|
||||
|
||||
func TestOpenclawHandleErrorEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "error",
|
||||
SessionID: "ses_abc",
|
||||
Data: map[string]any{"message": "Model not found: bad/model"},
|
||||
}
|
||||
|
||||
b.handleOCErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", status, "failed")
|
||||
}
|
||||
if errMsg != "Model not found: bad/model" {
|
||||
t.Errorf("error: got %q", errMsg)
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageError {
|
||||
t.Errorf("type: got %v, want MessageError", msg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleErrorEventCodeOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "error",
|
||||
Data: map[string]any{"code": "RateLimitError"},
|
||||
}
|
||||
|
||||
b.handleOCErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if errMsg != "RateLimitError" {
|
||||
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawHandleErrorEventNilData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 10)
|
||||
status := "completed"
|
||||
errMsg := ""
|
||||
|
||||
event := openclawEvent{Type: "error"}
|
||||
|
||||
b.handleOCErrorEvent(event, ch, &status, &errMsg)
|
||||
|
||||
if errMsg != "unknown openclaw error" {
|
||||
t.Errorf("error: got %q, want %q", errMsg, "unknown openclaw error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration-level tests: processEvents ──
|
||||
|
||||
func TestOpenclawProcessEventsHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
// Simulate a successful run: step_start → text → tool_call → text → step_end
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","sessionId":"ses_happy"}`,
|
||||
`{"type":"text","sessionId":"ses_happy","data":{"text":"Analyzing..."}}`,
|
||||
`{"type":"tool_call","sessionId":"ses_happy","data":{"name":"bash","callId":"call_1","input":{"command":"ls"},"status":"completed","output":"file.go\n"}}`,
|
||||
`{"type":"text","sessionId":"ses_happy","data":{"text":" Done."}}`,
|
||||
`{"type":"step_end","sessionId":"ses_happy"}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "completed")
|
||||
}
|
||||
if result.sessionID != "ses_happy" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
|
||||
}
|
||||
if result.output != "Analyzing... Done." {
|
||||
t.Errorf("output: got %q, want %q", result.output, "Analyzing... Done.")
|
||||
}
|
||||
if result.errMsg != "" {
|
||||
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
||||
}
|
||||
|
||||
// Drain and verify messages.
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
|
||||
// Expected: status(running), text, tool-use, tool-result, text = 5 messages
|
||||
if len(msgs) != 5 {
|
||||
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
|
||||
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
|
||||
}
|
||||
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing..." {
|
||||
t.Errorf("msg[1]: got %+v", msgs[1])
|
||||
}
|
||||
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
|
||||
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
|
||||
}
|
||||
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file.go\n" {
|
||||
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
|
||||
}
|
||||
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
|
||||
t.Errorf("msg[4]: got %+v", msgs[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsErrorCausesFailedStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","sessionId":"ses_err"}`,
|
||||
`{"type":"error","sessionId":"ses_err","data":{"message":"Model not found: bad/model"}}`,
|
||||
`{"type":"step_end","sessionId":"ses_err"}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "failed")
|
||||
}
|
||||
if result.errMsg != "Model not found: bad/model" {
|
||||
t.Errorf("errMsg: got %q", result.errMsg)
|
||||
}
|
||||
if result.sessionID != "ses_err" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var errorMsgs int
|
||||
for m := range ch {
|
||||
if m.Type == MessageError {
|
||||
errorMsgs++
|
||||
}
|
||||
}
|
||||
if errorMsgs != 1 {
|
||||
t.Errorf("expected 1 error message, got %d", errorMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsSessionIDExtracted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"step_start","sessionId":"ses_first"}`,
|
||||
`{"type":"text","sessionId":"ses_updated","data":{"text":"hi"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.sessionID != "ses_updated" {
|
||||
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsScannerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
result := b.processEvents(&ioErrReader{
|
||||
data: `{"type":"text","sessionId":"ses_scan","data":{"text":"before error"}}` + "\n",
|
||||
}, ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "failed")
|
||||
}
|
||||
if !strings.Contains(result.errMsg, "stdout read error") {
|
||||
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
|
||||
}
|
||||
if result.output != "before error" {
|
||||
t.Errorf("output: got %q, want %q", result.output, "before error")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsEmptyLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
"",
|
||||
" ",
|
||||
"not json at all",
|
||||
`{"type":"text","sessionId":"ses_ok","data":{"text":"valid"}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "completed")
|
||||
}
|
||||
if result.output != "valid" {
|
||||
t.Errorf("output: got %q, want %q", result.output, "valid")
|
||||
}
|
||||
if result.sessionID != "ses_ok" {
|
||||
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
var msgs []Message
|
||||
for m := range ch {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
if len(msgs) != 1 || msgs[0].Type != MessageText {
|
||||
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"error","sessionId":"ses_x","data":{"message":"RateLimitError"}}`,
|
||||
`{"type":"text","sessionId":"ses_x","data":{"text":"recovered?"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
|
||||
}
|
||||
if result.errMsg != "RateLimitError" {
|
||||
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsResultEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"text","sessionId":"ses_r","data":{"text":"Done"}}`,
|
||||
`{"type":"result","sessionId":"ses_r","data":{"status":"completed"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "completed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "completed")
|
||||
}
|
||||
if result.output != "Done" {
|
||||
t.Errorf("output: got %q, want %q", result.output, "Done")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func TestOpenclawProcessEventsResultErrorStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{cfg: Config{Logger: slog.Default()}}
|
||||
ch := make(chan Message, 256)
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"type":"result","sessionId":"ses_rf","data":{"status":"error","error":"out of tokens"}}`,
|
||||
}, "\n")
|
||||
|
||||
result := b.processEvents(strings.NewReader(lines), ch)
|
||||
|
||||
if result.status != "failed" {
|
||||
t.Errorf("status: got %q, want %q", result.status, "failed")
|
||||
}
|
||||
if result.errMsg != "out of tokens" {
|
||||
t.Errorf("errMsg: got %q, want %q", result.errMsg, "out of tokens")
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// ── openclawExtractText tests ──
|
||||
|
||||
func TestExtractEventTextDirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := map[string]any{"text": "hello"}
|
||||
if got := openclawExtractText(data); got != "hello" {
|
||||
t.Errorf("got %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEventTextNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := map[string]any{
|
||||
"content": map[string]any{"text": "nested hello"},
|
||||
}
|
||||
if got := openclawExtractText(data); got != "nested hello" {
|
||||
t.Errorf("got %q, want %q", got, "nested hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEventTextNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := openclawExtractText(nil); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thinking event with nested content ──
|
||||
|
||||
func TestOpenclawHandleThinkingEventNestedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := &openclawBackend{}
|
||||
ch := make(chan Message, 10)
|
||||
|
||||
event := openclawEvent{
|
||||
Type: "thinking",
|
||||
Data: map[string]any{
|
||||
"content": map[string]any{"text": "Nested thinking"},
|
||||
},
|
||||
}
|
||||
|
||||
b.handleOCThinkingEvent(event, ch)
|
||||
|
||||
if len(ch) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(ch))
|
||||
}
|
||||
msg := <-ch
|
||||
if msg.Type != MessageThinking {
|
||||
t.Errorf("type: got %v, want MessageThinking", msg.Type)
|
||||
}
|
||||
if msg.Content != "Nested thinking" {
|
||||
t.Errorf("content: got %q, want %q", msg.Content, "Nested thinking")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue