diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go new file mode 100644 index 0000000..bf22db9 --- /dev/null +++ b/backend/internal/adapters/agent/amp/amp.go @@ -0,0 +1,228 @@ +// Package amp implements the Amp agent adapter: launching new interactive Amp +// sessions and resuming sessions when a native Amp thread id is known. +// +// Amp activity hooks and SessionInfo derivation will likely require an +// Amp-specific TypeScript plugin, similar to opencode. Until that integration +// exists, hook installation and SessionInfo are intentionally no-ops. +package amp + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const adapterID = "amp" + +// Plugin is the Amp agent adapter. It is safe for concurrent use; the binary +// path is resolved once and cached under binaryMu. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Amp adapter. +func New() *Plugin { + return &Plugin{} +} + +var _ adapters.Adapter = (*Plugin)(nil) +var _ ports.Agent = (*Plugin)(nil) + +// Manifest returns the adapter's static self-description. +func (p *Plugin) Manifest() adapters.Manifest { + return adapters.Manifest{ + ID: adapterID, + Name: "Amp", + Description: "Run Amp worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports no agent-specific config keys yet. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive Amp session: +// +// amp [--permission-mode ] [--append-system-prompt ] [-- ] +// +// The prompt is passed after `--` so a prompt beginning with "-" is not +// mistaken for a flag. System prompts are appended to Amp's defaults, mirroring +// the Claude Code adapter's launch shape. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + binary, err := p.ampBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendPermissionFlags(&cmd, cfg.Permissions) + if cfg.SystemPromptFile != "" { + cmd = append(cmd, "--append-system-prompt-file", cfg.SystemPromptFile) + } else if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch +// command itself. +func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is intentionally a no-op until Amp activity can be reported via +// an Amp-specific plugin. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Amp session +// when plugin-derived native session metadata is available. Until that metadata +// exists, ok is false and callers fall back to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.ampBinary(ctx) + if err != nil { + return nil, false, err + } + // Capacity fits binary + up to two permission flags + --resume + sessionID. + cmd = make([]string, 0, 5) + cmd = append(cmd, binary) + appendPermissionFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until Amp plugin metadata exists. +func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { + switch mode { + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--permission-mode", "acceptEdits") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--permission-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--permission-mode", "bypassPermissions") + } +} + +// ResolveAmpBinary finds the `amp` binary, searching PATH then common install +// locations. It returns "amp" as a last resort so callers get the shell's normal +// command-not-found behavior if Amp is absent. +func ResolveAmpBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"amp.cmd", "amp.exe", "amp"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "amp.cmd"), + filepath.Join(appData, "npm", "amp.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "amp", nil + } + + if path, err := exec.LookPath("amp"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/amp", + "/opt/homebrew/bin/amp", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "amp"), + filepath.Join(home, ".npm", "bin", "amp"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "amp", nil +} + +func (p *Plugin) ampBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveAmpBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/amp/amp_test.go b/backend/internal/adapters/agent/amp/amp_test.go new file mode 100644 index 0000000..e2d9366 --- /dev/null +++ b/backend/internal/adapters/agent/amp/amp_test.go @@ -0,0 +1,212 @@ +package amp + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifest(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "amp" { + t.Fatalf("ID = %q, want amp", m.ID) + } + if m.Name != "Amp" { + t.Fatalf("Name = %q, want Amp", m.Name) + } + hasAgent := false + for _, c := range m.Capabilities { + if c == adapters.CapabilityAgent { + hasAgent = true + } + } + if !hasAgent { + t.Fatal("missing CapabilityAgent") + } +} + +func TestGetConfigSpecEmpty(t *testing.T) { + spec, err := (&Plugin{}).GetConfigSpec(context.Background()) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(spec.Fields) != 0 { + t.Fatalf("expected no fields, got %d", len(spec.Fields)) + } +} + +func TestGetPromptDeliveryStrategy(t *testing.T) { + s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != ports.PromptDeliveryInCommand { + t.Fatalf("strategy = %q, want %q", s, ports.PromptDeliveryInCommand) + } +} + +func TestGetLaunchCommandBypassWithPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--permission-mode", "bypassPermissions", "--", "-add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + mode ports.PermissionMode + want []string + wantAbsent string + }{ + {"default omits flag", ports.PermissionModeDefault, []string{"amp"}, "--permission-mode"}, + {"empty omits flag", "", []string{"amp"}, "--permission-mode"}, + {"accept edits", ports.PermissionModeAcceptEdits, []string{"amp", "--permission-mode", "acceptEdits"}, ""}, + {"auto", ports.PermissionModeAuto, []string{"amp", "--permission-mode", "auto"}, ""}, + {"bypass", ports.PermissionModeBypassPermissions, []string{"amp", "--permission-mode", "bypassPermissions"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.mode}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cmd, tt.want) { + t.Fatalf("cmd = %#v, want %#v", cmd, tt.want) + } + if tt.wantAbsent != "" { + for _, arg := range cmd { + if arg == tt.wantAbsent { + t.Fatalf("cmd = %#v unexpectedly contains %q", cmd, tt.wantAbsent) + } + } + } + }) + } +} + +func TestGetLaunchCommandAppendsSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "follow repo rules", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--append-system-prompt", "follow repo rules", "--", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetLaunchCommandPrefersSystemPromptFileFlag(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPromptFile: "/tmp/system.md", + SystemPrompt: "inline ignored", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"amp", "--append-system-prompt-file", "/tmp/system.md"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"}, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"amp", "--permission-mode", "bypassPermissions", "--resume", "T-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "amp"} + _, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestGetAgentHooksNoOp(t *testing.T) { + if err := (&Plugin{}).GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err != nil { + t.Fatalf("GetAgentHooks err = %v, want nil", err) + } +} + +func TestSessionInfoNoOp(t *testing.T) { + info, ok, err := (&Plugin{}).SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "T-abc123"}, + }) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatalf("ok=true with info %#v, want no-op false", info) + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := (&Plugin{}).GetConfigSpec(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("GetConfigSpec err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetLaunchCommand(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetLaunchCommand err = %v, want context.Canceled", err) + } + if _, err := (&Plugin{}).GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetPromptDeliveryStrategy err = %v, want context.Canceled", err) + } + if err := (&Plugin{}).GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetAgentHooks err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).GetRestoreCommand(ctx, ports.RestoreConfig{}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetRestoreCommand err = %v, want context.Canceled", err) + } + if _, _, err := (&Plugin{}).SessionInfo(ctx, ports.SessionRef{}); !errors.Is(err, context.Canceled) { + t.Fatalf("SessionInfo err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 4a6f7c0..d20a4bc 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" @@ -27,6 +28,7 @@ func Constructors() []adapters.Adapter { opencode.New(), grok.New(), droid.New(), + amp.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 62793c2..08945c2 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -95,6 +95,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessOpenCode, "opencode"}, {domain.HarnessGrok, "grok"}, {domain.HarnessDroid, "droid"}, + {domain.HarnessAmp, "amp"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)