mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-21 15:12:29 +02:00
- 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
382 lines
9.5 KiB
Go
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))
|
|
}
|