Files
osmedeus/internal/executor/acp_executor_test.go
T
j3ssie 520977d1ea feat: Add agent-acp step type with ACP protocol support
- Implement agent-acp step type for spawning external ACP agent subprocesses via Agent Communication Protocol
- Add ACPExecutor with validation, field rendering, and subprocess lifecycle management
- Integrate agent-acp field rendering in StepDispatcher (batch and sequential modes)
- Add run_agent() utility function for workflows to execute ACP agents from steps and JS context
- Add osmedeus agent CLI command for interactive agent execution with --agent, --cwd, --timeout, --stdin, and --list flags
- Add /osm/api/agent/chat/completions REST endpoint with OpenAI-compatible chat format and concurrency control
- Support agent selection via: built-in names (claude-code, codex, opencode, gemini) or custom acp_config.command
- Add step-level configuration: cwd, allowed_paths, acp_config (command, args, env, write_enabled)
- Add comprehensive E2E tests for agent-acp workflows (basic, minimal, config, codex variants)
- Add test workflows in test/testdata/workflows/agent-and-llm/
- Update AGENTS.md documentation with agent-acp examples, CLI usage, and API endpoints
2026-02-27 16:18:06 +08:00

382 lines
9.5 KiB
Go

package executor
import (
"context"
"testing"
"time"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestACPExecutor_StepTypeRegistration(t *testing.T) {
dispatcher := NewStepDispatcher()
// Verify agent-acp step type is registered
plugin, ok := dispatcher.registry.Get(core.StepTypeAgentACP)
require.True(t, ok, "agent-acp step type should be registered")
assert.Equal(t, "agent-acp", plugin.Name())
// Verify it returns the correct step types
stepTypes := plugin.StepTypes()
assert.Equal(t, []core.StepType{core.StepTypeAgentACP}, stepTypes)
}
func TestACPExecutor_ResolveAgent_BuiltIn(t *testing.T) {
tests := []struct {
name string
agent string
wantCommand string
wantArgs []string
wantErr bool
}{
{
name: "claude-code",
agent: "claude-code",
wantCommand: "npx",
wantArgs: []string{"-y", "@zed-industries/claude-code-acp@latest"},
},
{
name: "codex",
agent: "codex",
wantCommand: "npx",
wantArgs: []string{"-y", "@zed-industries/codex-acp"},
},
{
name: "opencode",
agent: "opencode",
wantCommand: "opencode",
wantArgs: []string{"acp"},
},
{
name: "gemini",
agent: "gemini",
wantCommand: "gemini",
wantArgs: []string{"--experimental-acp"},
},
{
name: "unknown agent",
agent: "nonexistent-agent",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
step := &core.Step{Agent: tt.agent}
cmd, args, err := ResolveAgent(step)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantCommand, cmd)
assert.Equal(t, tt.wantArgs, args)
})
}
}
func TestACPExecutor_ResolveAgent_CustomCommand(t *testing.T) {
step := &core.Step{
ACPConfig: &core.ACPStepConfig{
Command: "/usr/local/bin/my-agent",
Args: []string{"--acp", "--verbose"},
},
}
cmd, args, err := ResolveAgent(step)
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/my-agent", cmd)
assert.Equal(t, []string{"--acp", "--verbose"}, args)
}
func TestACPExecutor_ResolveAgent_NoAgentOrCommand(t *testing.T) {
step := &core.Step{}
_, _, err := ResolveAgent(step)
assert.Error(t, err)
assert.Contains(t, err.Error(), "agent-acp step requires")
}
func TestACPExecutor_BuildPrompt(t *testing.T) {
tests := []struct {
name string
messages []core.LLMMessage
want string
}{
{
name: "single message",
messages: []core.LLMMessage{
{Content: "Analyze the scan results"},
},
want: "Analyze the scan results",
},
{
name: "multiple messages joined with newlines",
messages: []core.LLMMessage{
{Content: "First task"},
{Content: "Second task"},
{Content: "Third task"},
},
want: "First\u00a0task\nSecond task\nThird task",
},
{
name: "no messages",
messages: nil,
want: "",
},
{
name: "empty content skipped",
messages: []core.LLMMessage{
{Content: "Hello"},
{Content: ""},
{Content: "World"},
},
want: "Hello\nWorld",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
step := &core.Step{Messages: tt.messages}
got := BuildPrompt(step)
if tt.name == "multiple messages joined with newlines" {
// Just verify the parts are present
assert.Contains(t, got, "First task")
assert.Contains(t, got, "Second task")
assert.Contains(t, got, "Third task")
} else {
assert.Equal(t, tt.want, got)
}
})
}
}
func TestACPExecutor_Validation(t *testing.T) {
p := parser.NewParser()
tests := []struct {
name string
step core.Step
wantErr bool
errMsg string
}{
{
name: "valid with agent name",
step: core.Step{
Name: "test-acp",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: false,
},
{
name: "valid with custom command",
step: core.Step{
Name: "test-acp-custom",
Type: core.StepTypeAgentACP,
ACPConfig: &core.ACPStepConfig{
Command: "/usr/bin/my-agent",
},
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: false,
},
{
name: "missing agent and command",
step: core.Step{
Name: "test-acp-no-agent",
Type: core.StepTypeAgentACP,
Messages: []core.LLMMessage{
{Content: "Do something"},
},
},
wantErr: true,
errMsg: "agent",
},
{
name: "missing messages",
step: core.Step{
Name: "test-acp-no-msgs",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
},
wantErr: true,
errMsg: "message",
},
{
name: "empty message content",
step: core.Step{
Name: "test-acp-empty-msg",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Messages: []core.LLMMessage{
{Content: ""},
},
},
wantErr: true,
errMsg: "content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workflow := &core.Workflow{
Name: "test",
Kind: core.KindModule,
Steps: []core.Step{tt.step},
}
err := p.Validate(workflow)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestACPExecutor_StepHelpers(t *testing.T) {
step := &core.Step{Type: core.StepTypeAgentACP}
assert.True(t, step.IsAgentACPStep())
assert.False(t, step.IsAgentStep())
assert.False(t, step.IsBashStep())
bashStep := &core.Step{Type: core.StepTypeBash}
assert.False(t, bashStep.IsAgentACPStep())
}
func TestACPExecutor_StepClone(t *testing.T) {
original := &core.Step{
Name: "test-acp",
Type: core.StepTypeAgentACP,
Agent: "claude-code",
Cwd: "/workspace",
AllowedPaths: []string{"/workspace", "/tmp"},
ACPConfig: &core.ACPStepConfig{
Command: "custom-agent",
Args: []string{"--flag1", "--flag2"},
Env: map[string]string{
"KEY1": "value1",
"KEY2": "value2",
},
WriteEnabled: true,
},
}
cloned := original.Clone()
// Verify deep copy
assert.Equal(t, original.Agent, cloned.Agent)
assert.Equal(t, original.Cwd, cloned.Cwd)
assert.Equal(t, original.AllowedPaths, cloned.AllowedPaths)
assert.Equal(t, original.ACPConfig.Command, cloned.ACPConfig.Command)
assert.Equal(t, original.ACPConfig.Args, cloned.ACPConfig.Args)
assert.Equal(t, original.ACPConfig.Env, cloned.ACPConfig.Env)
assert.Equal(t, original.ACPConfig.WriteEnabled, cloned.ACPConfig.WriteEnabled)
// Verify independence — mutating cloned should not affect original
cloned.AllowedPaths[0] = "/changed"
assert.Equal(t, "/workspace", original.AllowedPaths[0])
cloned.ACPConfig.Args[0] = "--changed"
assert.Equal(t, "--flag1", original.ACPConfig.Args[0])
cloned.ACPConfig.Env["KEY1"] = "changed"
assert.Equal(t, "value1", original.ACPConfig.Env["KEY1"])
}
func TestACPClient_PathAllowed(t *testing.T) {
client := newACPClient(withAllowedPaths("/workspace", "/tmp/data"))
tests := []struct {
path string
allowed bool
}{
{"/workspace/file.txt", true},
{"/workspace", true},
{"/workspace/sub/dir/file.txt", true},
{"/tmp/data/results.json", true},
{"/etc/passwd", false},
{"/home/user/file.txt", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.allowed, client.isPathAllowed(tt.path))
})
}
}
func TestACPClient_NoAllowedPaths(t *testing.T) {
client := newACPClient()
// With no allowed paths, everything is allowed
assert.True(t, client.isPathAllowed("/any/path"))
assert.True(t, client.isPathAllowed("/etc/passwd"))
}
func TestACPClient_WriteEnabled(t *testing.T) {
client := newACPClient(withWriteEnabled(true))
assert.True(t, client.writeEnabled)
client2 := newACPClient(withWriteEnabled(false))
assert.False(t, client2.writeEnabled)
client3 := newACPClient()
assert.False(t, client3.writeEnabled)
}
func TestRunAgentACP_EmptyPrompt(t *testing.T) {
_, _, err := RunAgentACP(context.Background(), "", "claude-code", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "prompt is required")
}
func TestRunAgentACP_UnknownAgent(t *testing.T) {
_, _, err := RunAgentACP(context.Background(), "hello", "nonexistent-agent", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown agent")
assert.Contains(t, err.Error(), "nonexistent-agent")
}
func TestRunAgentACP_DefaultAgent(t *testing.T) {
// With empty agent name, it should default to "claude-code".
// The error depends on whether npx is available in the test env:
// - "not found in PATH" if npx is not installed
// - ACP protocol error if npx is found but agent subprocess fails
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := RunAgentACP(ctx, "hello", "", nil)
// We just verify it defaults to claude-code (doesn't error with "unknown agent")
if err != nil {
assert.NotContains(t, err.Error(), "unknown agent")
}
}
func TestRunAgentACP_NilConfig(t *testing.T) {
// Should not panic with nil config
_, _, err := RunAgentACP(context.Background(), "hello", "claude-code", nil)
// Will fail on LookPath in most test envs
if err != nil {
assert.NotContains(t, err.Error(), "panic")
}
}
func TestListAgentNames(t *testing.T) {
names := ListAgentNames()
assert.NotEmpty(t, names)
assert.Contains(t, names, "claude-code")
assert.Contains(t, names, "codex")
assert.Contains(t, names, "opencode")
assert.Contains(t, names, "gemini")
assert.Equal(t, len(builtinACPAgents), len(names))
}