diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 775d65b..f02d0d5 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -11,7 +11,10 @@ package activitydispatch import ( "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/copilot" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -24,7 +27,10 @@ type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) var Derivers = map[string]DeriveFunc{ "claude-code": claudecode.DeriveActivityState, "codex": codex.DeriveActivityState, + "cursor": cursor.DeriveActivityState, "opencode": opencode.DeriveActivityState, + "qwen": qwen.DeriveActivityState, + "copilot": copilot.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go new file mode 100644 index 0000000..a321105 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/activity.go @@ -0,0 +1,38 @@ +package copilot + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Copilot CLI hook event onto an AO activity state. +// The bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in copilotManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the +// native Copilot event name. Keeping this beside hooks.go means the events AO +// installs and what they mean live in one place. +// +// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT +// fire in non-interactive `-p` mode, while preToolUse fires before every tool +// invocation (including ones that would prompt the user for approval) and is +// the most reliable signal in CLI pipe mode (-p). AO still installs every event +// so interactive resume and future modes report activity; the +// permission-request → waiting_input mapping (driven by preToolUse) is the one +// that always fires under AO's headless launch. +// +// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's +// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real +// session-end here. Until then, the lifecycle reaper marks a dead Copilot +// runtime exited even when the last hook signal was sticky waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go new file mode 100644 index 0000000..12d5349 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -0,0 +1,274 @@ +// Package copilot implements the GitHub Copilot CLI agent adapter: launching new +// headless sessions, resuming hook-tracked sessions, installing workspace-local +// hooks, and reading hook-derived session info. +// +// This adapter targets the standalone agentic GitHub Copilot CLI (binary +// "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` +// suggest/explain extension. +// +// Launch runs the CLI in non-interactive ("programmatic") mode with `-p +// ` so it executes the task and exits. Permission modes map onto the +// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Restore continues an existing session via `--resume `; the +// native session id (a UUID under ~/.copilot/session-state/) is captured by the +// SessionStart hook AO installs (see hooks.go). +// +// AO-managed sessions derive native session identity and display metadata from +// Copilot hooks instead of transcript/cache scans. +package copilot + +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 = "copilot" + + copilotTitleMetadataKey = "title" + copilotSummaryMetadataKey = "summary" +) + +// Plugin is the GitHub Copilot CLI 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 Copilot 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: "GitHub Copilot", + Description: "Run GitHub Copilot CLI worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Copilot exposes none 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 headless Copilot session: +// +// copilot [permission flags] [-p ] +// +// The prompt is delivered with `-p`, which runs the prompt in non-interactive +// mode and exits when done. Copilot CLI does not have a documented +// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.copilotBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the +// launch command itself (via `-p`). +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 +} + +// GetRestoreCommand rebuilds the argv that continues an existing Copilot +// session: `copilot [permission flags] --resume [-p ]`. +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. +// +// ports.RestoreConfig carries no Prompt field, so resume is issued without a new +// `-p`; the manager re-sends the prompt through its own delivery path when one is +// needed. +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.copilotBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Copilot hook-derived metadata. Metadata is intentionally +// nil for Copilot: callers get the normalized fields directly. +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 + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[copilotTitleMetadataKey], + Summary: session.Metadata[copilotSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveCopilotBinary returns the path to the copilot binary on this machine, +// searching PATH then a handful of well-known install locations (npm global, +// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear +// "command not found" rather than an empty argv. +func ResolveCopilotBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"copilot.cmd", "copilot.exe", "copilot"} { + 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", "copilot.cmd"), + filepath.Join(appData, "npm", "copilot.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe")) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "copilot", nil + } + + if path, err := exec.LookPath("copilot"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/copilot", + "/opt/homebrew/bin/copilot", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".copilot", "bin", "copilot"), + filepath.Join(home, ".npm", "bin", "copilot"), + filepath.Join(home, ".local", "bin", "copilot"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "copilot", nil +} + +func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCopilotBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval +// flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference): +// +// default → no flag (defer to ~/.copilot config / per-tool prompts) +// accept-edits → --allow-tool 'write' (auto-approve file edits only) +// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls) +// bypass-permissions → --allow-all (full bypass: tools, paths, urls) +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's ~/.copilot config / interactive prompts. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--allow-tool", "write") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--allow-all-tools") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--allow-all") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go new file mode 100644 index 0000000..d4c4c0c --- /dev/null +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -0,0 +1,462 @@ +package copilot + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestManifestID(t *testing.T) { + got := New().Manifest() + if got.ID != "copilot" { + t.Fatalf("Manifest().ID = %q, want %q", got.ID, "copilot") + } + if got.Name != "GitHub Copilot" { + t.Fatalf("Manifest().Name = %q, want %q", got.Name, "GitHub Copilot") + } +} + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"copilot", "--allow-all", "-p", "-fix this"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if contains(cmd, "-p") { + t.Fatalf("command %#v unexpectedly contains -p", cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected []string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"}, + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--allow-tool", "write"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--allow-all-tools"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--allow-all"}, + }, + { + name: "empty falls back to default", + permission: "", + notExpected: []string{"--allow-tool", "--allow-all-tools", "--allow-all"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + for _, ne := range tt.notExpected { + if contains(cmd, ne) { + t.Fatalf("command %#v unexpectedly contains %q", cmd, ne) + } + } + }) + } +} + +func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) { + plugin := New() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{Prompt: "hi"}); err == nil { + t.Fatal("GetLaunchCommand with canceled context: err = nil, want non-nil") + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{"copilot", "--allow-all-tools", "--resume", "uuid-123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "uuid-123", + copilotTitleMetadataKey: "Fix login redirect", + copilotSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "uuid-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Copilot", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestGetAgentHooksInstallsCopilotHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + workspace := t.TempDir() + + hooksPath := copilotHooksPath(workspace) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + // Seed a user-owned agentStop hook plus an unrelated top-level field; both + // must survive install. + existing := `{"version":1,"disableAllHooks":false,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var file copilotHookFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatal(err) + } + if file.Version != copilotHooksVersion { + t.Fatalf("version = %d, want %d", file.Version, copilotHooksVersion) + } + if file.DisableAllHooks == nil || *file.DisableAllHooks { + t.Fatalf("disableAllHooks not preserved: %#v", file.DisableAllHooks) + } + for _, spec := range copilotManagedHooks { + command := copilotHookCommandPrefix + spec.Command + if count := countCopilotHookCommand(file.Hooks[spec.Event], command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, file.Hooks[spec.Event]) + } + } + if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 { + t.Fatalf("existing agentStop hook was not preserved: %#v", file.Hooks["agentStop"]) + } +} + +func TestUninstallHooksRemovesCopilotHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + workspace := t.TempDir() + hooksPath := copilotHooksPath(workspace) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own agentStop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"version":1,"hooks":{"agentStop":[{"type":"command","bash":"custom stop hook","powershell":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var file copilotHookFile + if err := json.Unmarshal(data, &file); err != nil { + t.Fatal(err) + } + for _, spec := range copilotManagedHooks { + command := copilotHookCommandPrefix + spec.Command + if got := countCopilotHookCommand(file.Hooks[spec.Event], command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, command, got) + } + } + if countCopilotHookCommand(file.Hooks["agentStop"], "custom stop hook") != 1 { + t.Fatalf("user agentStop hook not preserved: %#v", file.Hooks["agentStop"]) + } +} + +func TestAreHooksInstalledMissingFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + installed, err := plugin.AreHooksInstalled(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if installed { + t.Fatal("AreHooksInstalled on empty workspace = true, want false") + } +} + +func TestHookMethodsRequireWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "copilot"} + ctx := context.Background() + + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("GetAgentHooks with empty WorkspacePath: err = nil, want non-nil") + } + if err := plugin.UninstallHooks(ctx, ""); err == nil { + t.Fatal("UninstallHooks with empty path: err = nil, want non-nil") + } + if _, err := plugin.AreHooksInstalled(ctx, ""); err == nil { + t.Fatal("AreHooksInstalled with empty path: err = nil, want non-nil") + } +} + +// TestCopilotManagedHooksUseDocumentedEventNames pins the JSON keys AO writes +// into .github/hooks/ao.json to the camelCase names Copilot CLI documents +// (https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks). +// Drifting back to lowercase-dashed or any other casing silently disables the +// hooks, so this is a tripwire for that class of regression. +func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { + wantEventByCommand := map[string]string{ + "session-start": "sessionStart", + "user-prompt-submit": "userPromptSubmitted", + "permission-request": "preToolUse", + "stop": "agentStop", + } + if len(copilotManagedHooks) != len(wantEventByCommand) { + t.Fatalf("copilotManagedHooks length = %d, want %d", len(copilotManagedHooks), len(wantEventByCommand)) + } + for _, spec := range copilotManagedHooks { + want, ok := wantEventByCommand[spec.Command] + if !ok { + t.Fatalf("unexpected AO sub-command %q in copilotManagedHooks", spec.Command) + } + if spec.Event != want { + t.Fatalf("command %q event = %q, want %q (Copilot CLI documented camelCase)", spec.Command, spec.Event, want) + } + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + event string + wantState domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tt := range tests { + t.Run(tt.event, func(t *testing.T) { + state, ok := DeriveActivityState(tt.event, nil) + if state != tt.wantState || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) + } + }) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + return false +} + +func countCopilotHookCommand(entries []copilotHookEntry, command string) int { + count := 0 + for _, entry := range entries { + if entry.Bash == command || entry.Powershell == command { + count++ + } + } + return count +} diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go new file mode 100644 index 0000000..f6fafe3 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -0,0 +1,297 @@ +package copilot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // copilotHooksDir is the repository-scope hooks directory Copilot CLI reads + // (.github/hooks/*.json). AO writes a single dedicated file there so it never + // disturbs other hook files the user or repo may ship. + copilotHooksDir = ".github/hooks" + copilotHooksFileName = "ao.json" + + // copilotHooksVersion is the schema version of the hooks file (Copilot uses 1). + copilotHooksVersion = 1 + + // copilotHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix without an + // embedded template to diff against. The CLI dispatcher routes + // `ao hooks copilot ` to DeriveActivityState. + copilotHookCommandPrefix = "ao hooks copilot " + copilotHookTimeoutSec = 30 +) + +// copilotHookFile is the on-disk shape of .github/hooks/ao.json. AO owns this +// dedicated file outright, so it only models the keys it manages (version, +// disableAllHooks, hooks); user-defined hooks live in their own .github/hooks/* +// files and are never touched. +type copilotHookFile struct { + Version int `json:"version"` + DisableAllHooks *bool `json:"disableAllHooks,omitempty"` + Hooks map[string][]copilotHookEntry `json:"hooks"` +} + +// copilotHookEntry is one hook command. Copilot entries carry separate bash and +// powershell command strings (both required for cross-platform), a type, an +// optional working dir, and a timeout in seconds. +type copilotHookEntry struct { + Type string `json:"type"` + Bash string `json:"bash,omitempty"` + Powershell string `json:"powershell,omitempty"` + Cwd string `json:"cwd,omitempty"` + TimeoutSec int `json:"timeoutSec,omitempty"` +} + +// copilotHookSpec describes one hook AO installs, defined in code rather than +// read from an embedded settings file. +type copilotHookSpec struct { + // Event is the native Copilot camelCase event name (sessionStart, ...). + Event string + // Command is the AO sub-command suffix (session-start, ...). It is appended + // to copilotHookCommandPrefix to form both the bash and powershell command, + // and is the value DeriveActivityState switches on. + Command string +} + +// copilotManagedHooks is the source of truth for the hooks AO installs. The AO +// sub-command names (session-start, user-prompt-submit, permission-request, +// stop) are exactly what DeriveActivityState in activity.go switches on. +// +// Native event names use Copilot's camelCase form, taken verbatim from +// https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks +// (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, +// errorOccurred, agentStop). Copilot does not document a "permissionRequest" +// event — the closest signal that AO's permission-request sub-command can +// piggyback on is preToolUse, which fires before any tool invocation, including +// the ones that would otherwise prompt the user for approval. This is a +// many-to-one collapse: every preToolUse currently produces ActivityWaitingInput +// via the permission-request sub-command. agentStop is the per-turn completion +// signal and maps to the "stop" sub-command (turn end → idle). +var copilotManagedHooks = []copilotHookSpec{ + {Event: "sessionStart", Command: "session-start"}, + {Event: "userPromptSubmitted", Command: "user-prompt-submit"}, + {Event: "preToolUse", Command: "permission-request"}, + {Event: "agentStop", Command: "stop"}, +} + +// GetAgentHooks installs AO's Copilot hooks into the worktree-local +// .github/hooks/ao.json file (the repository-scope hooks config Copilot CLI +// reads). The hooks report normalized activity-state signals back into AO's +// store. Existing AO entries are not duplicated and any unrelated keys are +// preserved, so the install is idempotent. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("copilot.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := copilotHooksPath(cfg.WorkspacePath) + file, err := readCopilotHooks(hooksPath) + if err != nil { + return fmt.Errorf("copilot.GetAgentHooks: %w", err) + } + + if file.Hooks == nil { + file.Hooks = map[string][]copilotHookEntry{} + } + for _, spec := range copilotManagedHooks { + command := copilotHookCommandPrefix + spec.Command + if copilotHookCommandExists(file.Hooks[spec.Event], command) { + continue + } + file.Hooks[spec.Event] = append(file.Hooks[spec.Event], copilotHookEntry{ + Type: "command", + Bash: command, + Powershell: command, + TimeoutSec: copilotHookTimeoutSec, + }) + } + + if err := writeCopilotHooks(hooksPath, file); err != nil { + return fmt.Errorf("copilot.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Copilot hooks from the workspace-local +// .github/hooks/ao.json file, leaving user-defined hooks and unrelated keys +// untouched. A missing file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("copilot.UninstallHooks: workspacePath is required") + } + + hooksPath := copilotHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + file, err := readCopilotHooks(hooksPath) + if err != nil { + return fmt.Errorf("copilot.UninstallHooks: %w", err) + } + + for event, entries := range file.Hooks { + kept := removeCopilotManagedHooks(entries) + if len(kept) == 0 { + delete(file.Hooks, event) + continue + } + file.Hooks[event] = kept + } + + if err := writeCopilotHooks(hooksPath, file); err != nil { + return fmt.Errorf("copilot.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Copilot hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("copilot.AreHooksInstalled: workspacePath is required") + } + + hooksPath := copilotHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + file, err := readCopilotHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("copilot.AreHooksInstalled: %w", err) + } + + for _, entries := range file.Hooks { + for _, entry := range entries { + if isCopilotManagedHook(entry) { + return true, nil + } + } + } + return false, nil +} + +func copilotHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, filepath.FromSlash(copilotHooksDir), copilotHooksFileName) +} + +// readCopilotHooks loads the hooks file. A missing or empty file yields an empty +// file struct with a nil hooks map (and the AO schema version, used on write). +func readCopilotHooks(hooksPath string) (copilotHookFile, error) { + file := copilotHookFile{Version: copilotHooksVersion} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return file, nil + } + if err != nil { + return copilotHookFile{}, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return file, nil + } + if err := json.Unmarshal(data, &file); err != nil { + return copilotHookFile{}, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if file.Version == 0 { + file.Version = copilotHooksVersion + } + return file, nil +} + +// writeCopilotHooks writes the file. An empty hooks map still writes a valid +// (versioned) file so AreHooksInstalled and re-install see a consistent shape. +func writeCopilotHooks(hooksPath string, file copilotHookFile) error { + if file.Version == 0 { + file.Version = copilotHooksVersion + } + if file.Hooks == nil { + file.Hooks = map[string][]copilotHookEntry{} + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hooks dir: %w", err) + } + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file in the same directory +// followed by a rename, so a crash or signal mid-write can't leave a truncated +// or empty file that Copilot then fails to parse (silently disabling hooks). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// isCopilotManagedHook reports whether an entry is one AO owns, recognized by the +// command prefix on either the bash or powershell command. +func isCopilotManagedHook(entry copilotHookEntry) bool { + return strings.HasPrefix(entry.Bash, copilotHookCommandPrefix) || + strings.HasPrefix(entry.Powershell, copilotHookCommandPrefix) +} + +func copilotHookCommandExists(entries []copilotHookEntry, command string) bool { + for _, entry := range entries { + if entry.Bash == command || entry.Powershell == command { + return true + } + } + return false +} + +// removeCopilotManagedHooks strips AO hook entries from a slice, preserving +// user-defined entries in order. +func removeCopilotManagedHooks(entries []copilotHookEntry) []copilotHookEntry { + kept := make([]copilotHookEntry, 0, len(entries)) + for _, entry := range entries { + if !isCopilotManagedHook(entry) { + kept = append(kept, entry) + } + } + return kept +} diff --git a/backend/internal/adapters/agent/cursor/activity.go b/backend/internal/adapters/agent/cursor/activity.go new file mode 100644 index 0000000..068ce98 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/activity.go @@ -0,0 +1,30 @@ +package cursor + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Cursor hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in cursorManagedHooks +// ("session-start", "user-prompt-submit", "stop", "permission-request"), not +// the native Cursor event name. Cursor currently has no SessionEnd/Notification +// equivalent in the adapter, so runtime exit still falls back to the reaper. +// +// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor +// adds a native session/process-end hook, map that hook to ActivityExited here. +// Until then, make sure the lifecycle reaper can still mark a dead Cursor +// runtime as exited even when the last hook signal was sticky waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/cursor/activity_test.go b/backend/internal/adapters/agent/cursor/activity_test.go new file mode 100644 index 0000000..9b9e132 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/activity_test.go @@ -0,0 +1,32 @@ +package cursor + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + name string + event string + want domain.ActivityState + wantOK bool + }{ + {"session start -> active", "session-start", domain.ActivityActive, true}, + {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, + {"stop -> idle", "stop", domain.ActivityIdle, true}, + {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, + {"unknown event -> no signal", "frobnicate", "", false}, + {"native event name -> no signal", "beforeShellExecution", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} diff --git a/backend/internal/adapters/agent/cursor/cursor.go b/backend/internal/adapters/agent/cursor/cursor.go new file mode 100644 index 0000000..20809bb --- /dev/null +++ b/backend/internal/adapters/agent/cursor/cursor.go @@ -0,0 +1,241 @@ +// Package cursor implements the Cursor CLI agent adapter: launching new +// sessions, resuming hook-tracked sessions, installing workspace-local hooks, +// and reading hook-derived session info. +// +// AO-managed sessions derive native session identity and display +// metadata from Cursor hooks instead of transcript/cache scans. The driven +// binary is `cursor-agent` (not the `cursor` editor binary). +package cursor + +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 ( + cursorTitleMetadataKey = "title" + cursorSummaryMetadataKey = "summary" +) + +// Plugin is the Cursor 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 Cursor 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: "cursor", + Name: "Cursor", + Description: "Run Cursor CLI agent worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Cursor exposes none 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 Cursor CLI session: +// +// cursor-agent -p --output-format stream-json --trust [permission flags] +// +// `-p` runs print/non-interactive mode, `--output-format stream-json` emits the +// machine-readable event stream AO consumes, and `--trust` skips the +// workspace-trust prompt. The prompt is positional and must come last, so a +// leading "-" is not read as a flag. +// +// Cursor has no inline/file system-prompt flag: it reads workspace rule files +// (AGENTS.md, .cursor/rules, CLAUDE.md). SystemPrompt/SystemPromptFile are +// therefore not injected via a launch flag here. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.cursorBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "-p", "--output-format", "stream-json", "--trust"} + appendApprovalFlags(&cmd, cfg.Permissions) + + // Prompt is positional and must be last. The `--` sentinel ends option + // parsing so a leading "-" in the prompt is not read as a flag. + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Cursor 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 +} + +// GetRestoreCommand rebuilds the argv that continues an existing Cursor CLI +// session: +// +// cursor-agent -p --output-format stream-json --trust [perm flags] --resume +// +// ok is false when the hook-derived native session id has not landed yet, so +// callers can fall back to fresh launch behavior. ports.RestoreConfig carries no +// prompt, so none is appended. +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.cursorBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 10) + cmd = append(cmd, binary, "-p", "--output-format", "stream-json", "--trust") + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--resume", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Cursor hook-derived metadata. Metadata is intentionally +// nil for Cursor: callers get the normalized fields directly. +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 + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[cursorTitleMetadataKey], + Summary: session.Metadata[cursorSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveCursorBinary returns the path to the cursor-agent binary on this +// machine, searching PATH then a handful of well-known install locations. +// Returns "cursor-agent" as a last-ditch fallback so callers see a clear +// "command not found" rather than an empty argv. +func ResolveCursorBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"cursor-agent.exe", "cursor-agent.cmd", "cursor-agent"} { + path, err := exec.LookPath(name) + if err == nil && path != "" { + return path, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "cursor-agent", nil + } + + if path, err := exec.LookPath("cursor-agent"); err == nil && path != "" { + return path, nil + } + + candidates := []string{} + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, ".local", "bin", "cursor-agent")) + } + candidates = append(candidates, + "/usr/local/bin/cursor-agent", + "/opt/homebrew/bin/cursor-agent", + ) + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cursor-agent", nil +} + +func (p *Plugin) cursorBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveCursorBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Cursor config approvalMode. + case ports.PermissionModeAcceptEdits: + // No dedicated accept-edits flag exists; cursor has no accept-edits + // flag, it is governed by .cursor/cli.json permissions. + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--force") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--yolo") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/cursor/cursor_test.go b/backend/internal/adapters/agent/cursor/cursor_test.go new file mode 100644 index 0000000..6600a9c --- /dev/null +++ b/backend/internal/adapters/agent/cursor/cursor_test.go @@ -0,0 +1,445 @@ +package cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPromptFile: filepath.Join("tmp", "prompt with spaces.md"), + SystemPrompt: "ignored", + }) + if err != nil { + t.Fatal(err) + } + + // System prompt is never injected via a flag for cursor; the prompt is + // positional and last, guarded by a `--` end-of-options sentinel so a + // leading "-" is not parsed as a flag. + want := []string{ + "cursor-agent", + "-p", "--output-format", "stream-json", "--trust", + "--yolo", + "--", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandOmitsPromptWhenEmpty(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeDefault, + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"cursor-agent", "-p", "--output-format", "stream-json", "--trust"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected []string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: []string{"--force", "--yolo"}, + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + notExpected: []string{"--force", "--yolo"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--force"}, + notExpected: []string{"--yolo"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--yolo"}, + notExpected: []string{"--force"}, + }, + { + name: "unknown falls back to default", + permission: "", + notExpected: []string{"--force", "--yolo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + for _, ne := range tt.notExpected { + if contains(cmd, ne) { + t.Fatalf("command %#v unexpectedly contains %q", cmd, ne) + } + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "chat-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "cursor-agent", + "-p", "--output-format", "stream-json", "--trust", + "--force", + "--resume", "chat-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "chat-123", + cursorTitleMetadataKey: "Fix login redirect", + cursorSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "chat-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Cursor", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestContextCancellationPerMethod(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: want context error") + } + // GetLaunchCommand surfaces ctx cancellation only via binary resolution; with + // a cached binary it short-circuits, so it is not asserted here (mirrors codex). + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: want context error") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "chat-123"}}, + }); err == nil { + t.Fatal("GetRestoreCommand: want context error") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: want context error") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err == nil { + t.Fatal("GetAgentHooks: want context error") + } + if err := plugin.UninstallHooks(ctx, t.TempDir()); err == nil { + t.Fatal("UninstallHooks: want context error") + } + if _, err := plugin.AreHooksInstalled(ctx, t.TempDir()); err == nil { + t.Fatal("AreHooksInstalled: want context error") + } +} + +func TestGetAgentHooksInstallsCursorHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, ".cursor") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + hooksPath := filepath.Join(hooksDir, "hooks.json") + // Pre-existing user hook on an event AO also manages, plus a non-AO field. + existing := `{"version":1,"customField":"keep me","hooks":{"stop":[{"command":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config cursorHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + if config.Version != 1 { + t.Fatalf("version = %d, want 1", config.Version) + } + for _, spec := range cursorManagedHooks { + entries := config.Hooks[spec.Event] + if count := countCursorHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command %q count = %d, want 1 in %#v", spec.Event, spec.Command, count, entries) + } + } + stopEntries := config.Hooks["stop"] + if countCursorHookCommand(stopEntries, "custom stop hook") != 1 { + t.Fatalf("existing stop hook was not preserved: %#v", stopEntries) + } + // Unmanaged top-level fields must be preserved. + if !strings.Contains(string(data), "keep me") { + t.Fatalf("unmanaged field 'customField' was dropped: %s", data) + } +} + +func TestUninstallHooksRemovesOnlyAOHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + workspace := t.TempDir() + hooksPath := filepath.Join(workspace, ".cursor", "hooks.json") + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"version":1,"hooks":{"stop":[{"command":"custom stop hook"}]}}` + if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var config cursorHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range cursorManagedHooks { + if got := countCursorHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countCursorHookCommand(config.Hooks["stop"], "custom stop hook") != 1 { + t.Fatalf("user stop hook not preserved: %#v", config.Hooks["stop"]) + } +} + +func TestAreHooksInstalledFalseWhenNoFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + workspace := t.TempDir() + + installed, err := plugin.AreHooksInstalled(context.Background(), workspace) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if installed { + t.Fatal("installed = true, want false for missing file") + } +} + +func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cursor-agent"} + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("want error for empty WorkspacePath") + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countCursorHookCommand(entries []cursorHookEntry, command string) int { + count := 0 + for _, hook := range entries { + if hook.Command == command { + count++ + } + } + return count +} diff --git a/backend/internal/adapters/agent/cursor/hooks.go b/backend/internal/adapters/agent/cursor/hooks.go new file mode 100644 index 0000000..7655f60 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/hooks.go @@ -0,0 +1,308 @@ +package cursor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + cursorHooksDirName = ".cursor" + cursorHooksFileName = "hooks.json" + + // cursorHooksSchemaVersion is the version Cursor's hooks.json declares. AO + // only sets it when creating a fresh file; an existing version is preserved. + cursorHooksSchemaVersion = 1 + + // cursorHookCommandPrefix identifies the hook commands AO owns, so + // install skips duplicates and uninstall recognizes AO entries by + // prefix without an embedded template to diff against. + cursorHookCommandPrefix = "ao hooks cursor " +) + +// cursorHookFile is the on-disk shape of .cursor/hooks.json. It is used by tests +// to decode the written file. Cursor keys hooks by camelCase native event name; +// each value is an array of objects carrying a "command" string. +type cursorHookFile struct { + Version int `json:"version"` + Hooks map[string][]cursorHookEntry `json:"hooks"` +} + +type cursorHookEntry struct { + Command string `json:"command"` +} + +// cursorHookSpec describes one hook AO installs, defined in code rather than +// read from an embedded hooks file. Event is Cursor's native camelCase event +// name; Command is the AO sub-command dispatched when the hook fires. +type cursorHookSpec struct { + Event string + Command string +} + +// cursorManagedHooks is the source of truth for the hooks AO installs. The +// native-event → AO-subcommand contract is FIXED: the orchestrator's CLI hook +// dispatch and activity.go agree on the sub-command names. +var cursorManagedHooks = []cursorHookSpec{ + {Event: "sessionStart", Command: cursorHookCommandPrefix + "session-start"}, + {Event: "beforeSubmitPrompt", Command: cursorHookCommandPrefix + "user-prompt-submit"}, + {Event: "stop", Command: cursorHookCommandPrefix + "stop"}, + {Event: "beforeShellExecution", Command: cursorHookCommandPrefix + "permission-request"}, + {Event: "beforeMCPExecution", Command: cursorHookCommandPrefix + "permission-request"}, +} + +// GetAgentHooks installs AO's Cursor hooks into the worktree-local +// .cursor/hooks.json file. Existing hook entries are preserved and duplicate +// AO commands are not appended. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("cursor.GetAgentHooks: WorkspacePath is required") + } + + hooksPath := cursorHooksPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readCursorHooks(hooksPath) + if err != nil { + return fmt.Errorf("cursor.GetAgentHooks: %w", err) + } + + for event, specs := range groupCursorHooksByEvent() { + var existing []cursorHookEntry + if err := parseCursorHookEvent(rawHooks, event, &existing); err != nil { + return fmt.Errorf("cursor.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !cursorHookCommandExists(existing, spec.Command) { + existing = append(existing, cursorHookEntry{Command: spec.Command}) + } + } + if err := marshalCursorHookEvent(rawHooks, event, existing); err != nil { + return fmt.Errorf("cursor.GetAgentHooks: %w", err) + } + } + + if err := writeCursorHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("cursor.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Cursor hooks from the workspace-local +// .cursor/hooks.json file, leaving user-defined hooks untouched. A missing file +// is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("cursor.UninstallHooks: workspacePath is required") + } + + hooksPath := cursorHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readCursorHooks(hooksPath) + if err != nil { + return fmt.Errorf("cursor.UninstallHooks: %w", err) + } + + for _, event := range cursorManagedEvents() { + var entries []cursorHookEntry + if err := parseCursorHookEvent(rawHooks, event, &entries); err != nil { + return fmt.Errorf("cursor.UninstallHooks: %w", err) + } + entries = removeCursorManagedHooks(entries) + if err := marshalCursorHookEvent(rawHooks, event, entries); err != nil { + return fmt.Errorf("cursor.UninstallHooks: %w", err) + } + } + + if err := writeCursorHooks(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("cursor.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Cursor hook is present in the +// workspace-local hooks file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("cursor.AreHooksInstalled: workspacePath is required") + } + + hooksPath := cursorHooksPath(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readCursorHooks(hooksPath) + if err != nil { + return false, fmt.Errorf("cursor.AreHooksInstalled: %w", err) + } + + for _, event := range cursorManagedEvents() { + var entries []cursorHookEntry + if err := parseCursorHookEvent(rawHooks, event, &entries); err != nil { + return false, fmt.Errorf("cursor.AreHooksInstalled: %w", err) + } + for _, hook := range entries { + if isCursorManagedHook(hook.Command) { + return true, nil + } + } + } + return false, nil +} + +func cursorHooksPath(workspacePath string) string { + return filepath.Join(workspacePath, cursorHooksDirName, cursorHooksFileName) +} + +// readCursorHooks loads the hooks file into a top-level raw map plus the decoded +// "hooks" sub-map, preserving keys AO doesn't manage (e.g. "version"). A missing +// or empty file yields empty maps. +func readCursorHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeCursorHooks folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. A "version" key is ensured so +// a freshly created file declares the schema version Cursor expects, while an +// existing version (preserved in topLevel) is left untouched. +func writeCursorHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + if _, ok := topLevel["version"]; !ok { + versionJSON, err := json.Marshal(cursorHooksSchemaVersion) + if err != nil { + return fmt.Errorf("encode version: %w", err) + } + topLevel["version"] = versionJSON + } + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +// groupCursorHooksByEvent groups the managed hook specs by their Cursor event so +// each event's array is rewritten once. +func groupCursorHooksByEvent() map[string][]cursorHookSpec { + byEvent := map[string][]cursorHookSpec{} + for _, spec := range cursorManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// cursorManagedEvents returns the distinct Cursor events AO manages, in the +// order they first appear in cursorManagedHooks. +func cursorManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(cursorManagedHooks)) + for _, spec := range cursorManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isCursorManagedHook(command string) bool { + return strings.HasPrefix(command, cursorHookCommandPrefix) +} + +// removeCursorManagedHooks strips AO hook entries from an event's array, +// preserving user-defined entries. +func removeCursorManagedHooks(entries []cursorHookEntry) []cursorHookEntry { + kept := make([]cursorHookEntry, 0, len(entries)) + for _, hook := range entries { + if !isCursorManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + return kept +} + +func parseCursorHookEvent(rawHooks map[string]json.RawMessage, event string, target *[]cursorHookEntry) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalCursorHookEvent(rawHooks map[string]json.RawMessage, event string, entries []cursorHookEntry) error { + if len(entries) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func cursorHookCommandExists(entries []cursorHookEntry, command string) bool { + for _, hook := range entries { + if hook.Command == command { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/grok/grok.go b/backend/internal/adapters/agent/grok/grok.go new file mode 100644 index 0000000..faa5001 --- /dev/null +++ b/backend/internal/adapters/agent/grok/grok.go @@ -0,0 +1,289 @@ +// Package grok implements the Grok Build (xAI) agent adapter. +// +// Grok Build is xAI's terminal coding agent (binary "grok"). It supports +// Claude Code compatibility for hooks, skills, etc., so we reuse the claude +// hook installation (which writes .claude/settings.local.json with AO +// hook commands). Grok will pick them up via its compat layer. +// +// Launch uses `-p ` for the initial task (in-command delivery). +// Permission bypass uses `--always-approve`. We also pass `--no-auto-update` +// for headless/scripted use (parity with Codex no-update). +// Restore prefers the hook-captured native session id via `-r `. +// +// SessionInfo and title/summary flow through the shared claude hook path +// (when the hook handlers are extended to persist them). +package grok + +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/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Plugin is the Grok Build agent adapter. +type Plugin struct { + binaryMu sync.Mutex + resolvedBinary string +} + +// New returns a ready-to-register Grok 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: "grok", + Name: "Grok Build", + Description: "Run xAI Grok Build 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 `grok --no-auto-update [--permission-mode ] -p `. +// Prompt is delivered via -p (in command). +// +// Uses --permission-mode (acceptEdits / auto / bypassPermissions) to match +// `grok -h` output. Default omits the flag so Grok uses its config. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.grokBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--no-auto-update"} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. +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 reuses the Claude Code hook installer because Grok Build +// has a full Claude Code compatibility layer. +// +// Official docs (https://docs.x.ai/build/features/skills-plugins-marketplaces#claude-code-compatibility:~:text=tasks%20in%20parallel.-,Claude%20Code%20compatibility,-Grok%20is%20fully): +// +// "Grok is fully compatible with Claude Code with zero configuration needed. +// Grok automatically reads Claude Code ... hooks ... alongside .grok/." +// +// This means Grok will pick up the .claude/settings.local.json (and the +// AO hook commands we install there) in the worktree. The hook payloads for +// SessionStart / UserPromptSubmit / Stop etc. are compatible, so we get +// title/summary/agentSessionId + activity for free without a separate native +// .grok/hooks/ implementation or code duplication. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + // Delegate; the installed commands will be "ao hooks claude-code " + // so the existing CLI hook dispatcher routes them to claude derive logic. + // This works because of Grok's documented zero-config Claude compat. + return (&claudecode.Plugin{}).GetAgentHooks(ctx, cfg) +} + +// UninstallHooks removes the Claude Code-compatible AO hooks Grok uses. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + return (&claudecode.Plugin{}).UninstallHooks(ctx, workspacePath) +} + +// AreHooksInstalled reports whether the delegated Claude Code-compatible AO +// hooks are present for this Grok workspace. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + return (&claudecode.Plugin{}).AreHooksInstalled(ctx, workspacePath) +} + +// GetRestoreCommand resumes a prior grok session by its captured id, building +// `grok --no-auto-update [--permission-mode ] -r ` +// when we have a hook-captured native id. ok=false otherwise (fall back to fresh +// launch in the manager). +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.grokBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 4) + cmd = append(cmd, binary, "--no-auto-update") + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo reads hook-derived metadata. Since we delegate hook install to +// claude hooks (via compat), the keys in the metadata map are the claude ones +// ("title", "summary", "agentSessionId"). We surface them under the normalized +// SessionInfo; grok-specific aliases are not needed. +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 + } + // The keys written by claude hooks (which we install for grok too). + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[ports.MetadataKeyTitle], + Summary: session.Metadata[ports.MetadataKeySummary], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI). +func ResolveGrokBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"grok.cmd", "grok.exe", "grok"} { + 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", "grok.cmd"), + filepath.Join(appData, "npm", "grok.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".grok", "bin", "grok.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "grok", nil + } + + if path, err := exec.LookPath("grok"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/grok", + "/opt/homebrew/bin/grok", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".grok", "bin", "grok"), + filepath.Join(home, ".local", "bin", "grok"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "grok", nil +} + +func (p *Plugin) grokBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveGrokBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's ~/.grok/config.toml (or default behavior). + 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") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/grok/grok_test.go b/backend/internal/adapters/agent/grok/grok_test.go new file mode 100644 index 0000000..2cac03d --- /dev/null +++ b/backend/internal/adapters/agent/grok/grok_test.go @@ -0,0 +1,199 @@ +package grok + +import ( + "context" + "reflect" + "strings" + "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 != "grok" { + t.Fatalf("ID = %q, want grok", m.ID) + } + if m.Name != "Grok Build" { + t.Fatalf("Name = %q", 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 in_command", s) + } +} + +func TestGetLaunchCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "do the thing", + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + wantPrefix := []string{"grok", "--no-auto-update", "--permission-mode", "bypassPermissions", "-p", "do the thing"} + if !reflect.DeepEqual(cmd, wantPrefix) { + t.Fatalf("cmd = %#v, want prefix %#v", cmd, wantPrefix) + } +} + +func TestGetLaunchCommandDefaultPerms(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "fix it", + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(cmd) < 4 || cmd[0] != "grok" || cmd[1] != "--no-auto-update" || cmd[2] != "-p" { + t.Fatalf("cmd = %#v, want grok --no-auto-update -p ...", cmd) + } + if strings.Contains(strings.Join(cmd, " "), "permission-mode") { + t.Fatal("should not have --permission-mode for default perms") + } +} + +func TestGetLaunchCommandAcceptEdits(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Prompt: "refactor auth", + Permissions: ports.PermissionModeAcceptEdits, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"grok", "--no-auto-update", "--permission-mode", "acceptEdits", "-p", "refactor auth"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-abc123", + }, + }, + Permissions: ports.PermissionModeBypassPermissions, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + want := []string{"grok", "--no-auto-update", "--permission-mode", "bypassPermissions", "-r", "sess-abc123"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + _, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{Metadata: map[string]string{}}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "grok-ses-1", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if !ok { + t.Fatal("ok=false, want true") + } + if info.AgentSessionID != "grok-ses-1" { + t.Fatalf("AgentSessionID = %q, want grok-ses-1", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q", info.Summary) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "grok"} + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Fatalf("ok=true with empty metadata, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero", info) + } +} + +func TestHookLifecycleDelegates(t *testing.T) { + // Claude tests cover the full merge behavior; here we assert Grok exposes + // the same delegated lifecycle so Grok-installed compat hooks can be + // detected and removed through the Grok adapter. + plugin := &Plugin{resolvedBinary: "grok"} + ctx := context.Background() + ws := t.TempDir() + cfg := ports.WorkspaceHookConfig{ + WorkspacePath: ws, + SessionID: "grok-test-1", + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatalf("GetAgentHooks: %v", err) + } + if installed, err := plugin.AreHooksInstalled(ctx, ws); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + if err := plugin.UninstallHooks(ctx, ws); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + if installed, err := plugin.AreHooksInstalled(ctx, ws); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } +} diff --git a/backend/internal/adapters/agent/kimi/kimi.go b/backend/internal/adapters/agent/kimi/kimi.go new file mode 100644 index 0000000..5ff4127 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/kimi.go @@ -0,0 +1,270 @@ +// Package kimi implements the Kimi CLI (Moonshot AI) agent adapter: launching +// new non-interactive sessions and resuming sessions when a native Kimi session +// id is known. +// +// Kimi CLI (binary "kimi") is Moonshot AI's terminal-native agentic coding +// agent. A new task is run non-interactively with `kimi -p `, which +// streams the assistant output to stdout without opening the TUI. Sessions are +// resumed by id with `kimi --session `. +// +// Kimi exposes no native lifecycle/hook system and is not documented as +// Claude Code hook-compatible, so this is a Tier C adapter: hook installation +// and SessionInfo are intentionally no-ops, and activity is left to the +// lifecycle reaper. There is also no documented system-prompt flag, so AO's +// system prompt is not injected. Both should be upgraded if/when Kimi adds the +// corresponding CLI surface. +package kimi + +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 = "kimi" + +// Plugin is the Kimi CLI 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 Kimi 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: "Kimi", + Description: "Run Kimi CLI (Moonshot AI) 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 Kimi session: +// +// kimi -p (non-interactive, default) +// kimi [--yolo|--auto] (interactive, no prompt) +// +// When a prompt is supplied, it is delivered via `-p` (in command), which runs +// a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be +// combined with `--yolo`, `--auto`, or `--plan` — non-interactive mode already +// uses the `auto` permission policy by default, so approval flags would be +// rejected at startup. They are only emitted on the (interactive) path with no +// prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt / +// cfg.SystemPromptFile are not injected. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.kimiBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + return cmd, nil + } + + appendApprovalFlags(&cmd, cfg.Permissions) + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Kimi 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: Kimi CLI exposes no native hook system +// and is not documented as Claude Code hook-compatible. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand rebuilds the argv that continues an existing Kimi session +// when a native Kimi session id is known: +// +// kimi --session +// +// ok is false when no native session id has been captured, so callers fall back +// to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be +// combined with `--session` (or `--continue`) — resumed sessions inherit the +// approval settings of the original session — so cfg.Permissions is +// intentionally ignored here. Kimi has no lifecycle hook for AO to capture the +// native session id from yet, so in practice this returns ok=false today. +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.kimiBinary(ctx) + if err != nil { + return nil, false, err + } + cmd = []string{binary, "--session", agentSessionID} + return cmd, true, nil +} + +// SessionInfo is intentionally a no-op until Kimi exposes a way to capture its +// native session id and display metadata. +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 +} + +// appendApprovalFlags maps AO's permission modes onto Kimi's approval flags +// for interactive launches. Per Kimi docs these flags cannot be combined with +// `--prompt`, `--session`, or `--continue`, so callers on those paths must +// skip this mapping. +// +// - Default: no flag, deferring to the user's Kimi config/default behavior. +// - AcceptEdits / Auto: `--auto` (auto permission mode; approvals handled +// automatically). +// - BypassPermissions: `-y` (yolo; auto-approve regular tool calls including +// file writes and shell execution). +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Kimi config/default behavior. + case ports.PermissionModeAcceptEdits, ports.PermissionModeAuto: + *cmd = append(*cmd, "--auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "-y") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +// ResolveKimiBinary finds the `kimi` binary, searching PATH then common install +// locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew +// and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the +// shell's normal command-not-found behavior if Kimi is absent. +func ResolveKimiBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"kimi.cmd", "kimi.exe", "kimi"} { + 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", "kimi.cmd"), + filepath.Join(appData, "npm", "kimi.exe"), + ) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "kimi.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + return "kimi", nil + } + + if path, err := exec.LookPath("kimi"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/kimi", + "/opt/homebrew/bin/kimi", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".local", "bin", "kimi"), + filepath.Join(home, ".cargo", "bin", "kimi"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "kimi", nil +} + +func (p *Plugin) kimiBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveKimiBinary(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/kimi/kimi_test.go b/backend/internal/adapters/agent/kimi/kimi_test.go new file mode 100644 index 0000000..bbcbc0a --- /dev/null +++ b/backend/internal/adapters/agent/kimi/kimi_test.go @@ -0,0 +1,258 @@ +package kimi + +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 != "kimi" { + t.Fatalf("ID = %q, want kimi", m.ID) + } + if m.Name != "Kimi" { + t.Fatalf("Name = %q, want Kimi", 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) + } +} + +// Kimi docs: `--prompt` cannot be combined with `--yolo`, `--auto`, or `--plan` +// — non-interactive mode already runs under the `auto` permission policy. The +// adapter must not emit approval flags on the `-p` launch path regardless of +// the requested AO PermissionMode. +func TestGetLaunchCommandWithPromptOmitsApprovalFlags(t *testing.T) { + modes := []ports.PermissionMode{ + ports.PermissionModeDefault, + "", + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + p := &Plugin{resolvedBinary: "kimi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: mode, + Prompt: "-add a health check", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"kimi", "-p", "-add a health check"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } + for _, arg := range cmd { + switch arg { + case "--auto", "-y", "--yolo", "--yes", "--auto-approve", "--plan": + t.Fatalf("cmd = %#v unexpectedly contains approval/plan flag %q", cmd, arg) + } + } + }) + } +} + +// Without a prompt the launch is interactive, so approval flags are valid and +// the AO PermissionMode mapping applies. +func TestGetLaunchCommandInteractiveMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + mode ports.PermissionMode + want []string + wantAbsent string + }{ + {"default omits flag", ports.PermissionModeDefault, []string{"kimi"}, "--auto"}, + {"empty omits flag", "", []string{"kimi"}, "--auto"}, + {"accept edits", ports.PermissionModeAcceptEdits, []string{"kimi", "--auto"}, "-y"}, + {"auto", ports.PermissionModeAuto, []string{"kimi", "--auto"}, "-y"}, + {"bypass", ports.PermissionModeBypassPermissions, []string{"kimi", "-y"}, "--auto"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{resolvedBinary: "kimi"} + 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 TestGetLaunchCommandIgnoresSystemPrompt(t *testing.T) { + p := &Plugin{resolvedBinary: "kimi"} + cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "follow repo rules", + SystemPromptFile: "/tmp/system.md", + Prompt: "do the thing", + }) + if err != nil { + t.Fatal(err) + } + + // Kimi has no documented system-prompt flag, so neither is injected. + want := []string{"kimi", "-p", "do the thing"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } +} + +// Kimi docs: `--yolo` and `--auto` cannot be used together with `--continue` +// or `--session` — resumed sessions inherit the approval settings of the +// original session — so the restore path must not emit approval flags +// regardless of the requested AO PermissionMode. +func TestGetRestoreCommand(t *testing.T) { + modes := []ports.PermissionMode{ + ports.PermissionModeDefault, + "", + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + p := &Plugin{resolvedBinary: "kimi"} + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "01HZABC"}, + }, + Permissions: mode, + }) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("ok=false, want true") + } + + want := []string{"kimi", "--session", "01HZABC"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("cmd = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + switch arg { + case "--auto", "-y", "--yolo", "--yes", "--auto-approve", "--plan": + t.Fatalf("cmd = %#v unexpectedly contains approval/plan flag %q", cmd, arg) + } + } + }) + } +} + +func TestGetRestoreCommandNoID(t *testing.T) { + p := &Plugin{resolvedBinary: "kimi"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{Session: tc.ref}) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("ok=true with no agentSessionId, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +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: "01HZABC"}, + }) + 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{}).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) + } + if _, err := ResolveKimiBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolveKimiBinary err = %v, want context.Canceled", err) + } +} diff --git a/backend/internal/adapters/agent/qwen/activity.go b/backend/internal/adapters/agent/qwen/activity.go new file mode 100644 index 0000000..c2c0f53 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/activity.go @@ -0,0 +1,31 @@ +package qwen + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Qwen Code hook event onto an AO activity state. The +// bool is false when the event carries no activity signal. +// +// event is the AO hook sub-command name installed in qwenManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), not +// the native Qwen event name. Qwen Code has no SessionEnd equivalent in the +// adapter yet, so runtime exit still falls back to the reaper. +// +// TODO(qwen): ActivityExited is still runtime-observation-owned. Qwen Code has a +// native SessionEnd hook; if AO installs it, map "session-end" to +// ActivityExited here. Until then, make sure the lifecycle reaper can still mark +// a dead Qwen runtime as exited even when the last hook signal was sticky +// waiting_input. +func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/qwen/hooks.go b/backend/internal/adapters/agent/qwen/hooks.go new file mode 100644 index 0000000..a488e22 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/hooks.go @@ -0,0 +1,378 @@ +package qwen + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + qwenSettingsDirName = ".qwen" + qwenSettingsFileName = "settings.json" + + // qwenHookCommandPrefix identifies the hook commands AO owns. Every managed + // command starts with it, so install can skip duplicates and uninstall can + // recognize AO entries by prefix without an embedded template to diff + // against. + qwenHookCommandPrefix = "ao hooks qwen " + + // qwenHookTimeout is in milliseconds: Qwen Code (a gemini-cli fork) measures + // hook timeouts in ms, unlike Claude/Codex which use seconds. + qwenHookTimeout = 30000 +) + +// qwenHookFile is the on-disk shape of the "hooks" sub-object of +// .qwen/settings.json. It is used by tests to decode the written file. +type qwenHookFile struct { + Hooks map[string][]qwenMatcherGroup `json:"hooks"` +} + +type qwenMatcherGroup struct { + // Matcher is a pointer so it round-trips exactly: SessionStart targets the + // payload "source" field with a "startup" matcher; UserPromptSubmit/Stop/ + // PermissionRequest omit it (Qwen ignores the matcher for those events). + // omitempty drops a nil matcher on write. + Matcher *string `json:"matcher,omitempty"` + Hooks []qwenHookEntry `json:"hooks"` +} + +type qwenHookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// qwenHookSpec describes one hook AO installs, defined in code rather than read +// from an embedded settings file. +type qwenHookSpec struct { + Event string + Matcher *string + Command string +} + +// qwenStartupMatcher is referenced by pointer so SessionStart serializes with +// its "startup" source matcher. +var qwenStartupMatcher = "startup" + +// qwenManagedHooks is the source of truth for the hooks AO installs: +// SessionStart (under the "startup" source matcher), UserPromptSubmit, +// PermissionRequest, and Stop. They report normalized session metadata and +// activity-state signals back into AO's store (see DeriveActivityState). The +// AO sub-command names are FIXED and must match the cases in +// DeriveActivityState. +var qwenManagedHooks = []qwenHookSpec{ + {Event: "SessionStart", Matcher: &qwenStartupMatcher, Command: qwenHookCommandPrefix + "session-start"}, + {Event: "UserPromptSubmit", Command: qwenHookCommandPrefix + "user-prompt-submit"}, + {Event: "PermissionRequest", Command: qwenHookCommandPrefix + "permission-request"}, + {Event: "Stop", Command: qwenHookCommandPrefix + "stop"}, +} + +// GetAgentHooks installs AO's Qwen Code hooks into the worktree-local +// .qwen/settings.json file (the project-level settings). The hooks +// (SessionStart, UserPromptSubmit, PermissionRequest, Stop) report normalized +// session metadata and activity signals back into AO's store. Existing hooks +// and unrelated settings are preserved, and duplicate AO commands are not +// appended, so the install is idempotent. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(cfg.WorkspacePath) == "" { + return errors.New("qwen.GetAgentHooks: WorkspacePath is required") + } + + settingsPath := qwenSettingsPath(cfg.WorkspacePath) + topLevel, rawHooks, err := readQwenSettings(settingsPath) + if err != nil { + return fmt.Errorf("qwen.GetAgentHooks: %w", err) + } + + for event, specs := range groupQwenHooksByEvent() { + var existingGroups []qwenMatcherGroup + if err := parseQwenHookType(rawHooks, event, &existingGroups); err != nil { + return fmt.Errorf("qwen.GetAgentHooks: %w", err) + } + for _, spec := range specs { + if !qwenHookCommandExists(existingGroups, spec.Command) { + entry := qwenHookEntry{Type: "command", Command: spec.Command, Timeout: qwenHookTimeout} + existingGroups = addQwenHook(existingGroups, entry, spec.Matcher) + } + } + if err := marshalQwenHookType(rawHooks, event, existingGroups); err != nil { + return fmt.Errorf("qwen.GetAgentHooks: %w", err) + } + } + + if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("qwen.GetAgentHooks: %w", err) + } + return nil +} + +// UninstallHooks removes AO's Qwen Code hooks from the workspace-local +// .qwen/settings.json file, leaving user-defined hooks and unrelated settings +// untouched. A missing settings file is a no-op. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return errors.New("qwen.UninstallHooks: workspacePath is required") + } + + settingsPath := qwenSettingsPath(workspacePath) + if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readQwenSettings(settingsPath) + if err != nil { + return fmt.Errorf("qwen.UninstallHooks: %w", err) + } + + for _, event := range qwenManagedEvents() { + var groups []qwenMatcherGroup + if err := parseQwenHookType(rawHooks, event, &groups); err != nil { + return fmt.Errorf("qwen.UninstallHooks: %w", err) + } + groups = removeQwenManagedHooks(groups) + if err := marshalQwenHookType(rawHooks, event, groups); err != nil { + return fmt.Errorf("qwen.UninstallHooks: %w", err) + } + } + + if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("qwen.UninstallHooks: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether any AO Qwen Code hook is present in the +// workspace-local settings file. A missing file means none are installed. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, errors.New("qwen.AreHooksInstalled: workspacePath is required") + } + + settingsPath := qwenSettingsPath(workspacePath) + if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readQwenSettings(settingsPath) + if err != nil { + return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err) + } + + for _, event := range qwenManagedEvents() { + var groups []qwenMatcherGroup + if err := parseQwenHookType(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if isQwenManagedHook(hook.Command) { + return true, nil + } + } + } + } + return false, nil +} + +func qwenSettingsPath(workspacePath string) string { + return filepath.Join(workspacePath, qwenSettingsDirName, qwenSettingsFileName) +} + +// readQwenSettings loads the settings file into a top-level raw map plus the +// decoded "hooks" sub-map, preserving every key AO doesn't manage. A missing or +// empty file yields empty maps. +func readQwenSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeQwenSettings folds rawHooks back into topLevel and writes the file. An +// empty hooks map drops the "hooks" key entirely. +func writeQwenSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return fmt.Errorf("create settings dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", settingsPath, err) + } + data = append(data, '\n') + if err := atomicWriteFile(settingsPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", settingsPath, err) + } + return nil +} + +// atomicWriteFile writes data to path via a temp file in the same directory +// followed by a rename, so a crash or signal mid-write can't leave a truncated +// or empty file that Qwen Code then fails to parse (silently disabling hooks). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +// groupQwenHooksByEvent groups the managed hook specs by their Qwen event so +// each event's settings array is rewritten once. +func groupQwenHooksByEvent() map[string][]qwenHookSpec { + byEvent := map[string][]qwenHookSpec{} + for _, spec := range qwenManagedHooks { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// qwenManagedEvents returns the distinct Qwen events AO manages, in the order +// they first appear in qwenManagedHooks. +func qwenManagedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(qwenManagedHooks)) + for _, spec := range qwenManagedHooks { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +func isQwenManagedHook(command string) bool { + return strings.HasPrefix(command, qwenHookCommandPrefix) +} + +// removeQwenManagedHooks strips AO hook entries from every group, dropping any +// group left without hooks so the event array doesn't accumulate empty matcher +// objects. +func removeQwenManagedHooks(groups []qwenMatcherGroup) []qwenMatcherGroup { + result := make([]qwenMatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]qwenHookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !isQwenManagedHook(hook.Command) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func parseQwenHookType(rawHooks map[string]json.RawMessage, event string, target *[]qwenMatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalQwenHookType(rawHooks map[string]json.RawMessage, event string, groups []qwenMatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func qwenHookCommandExists(groups []qwenMatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +// addQwenHook appends hook to an existing group with the same matcher (so a +// SessionStart hook lands under its "startup" matcher), creating that group if +// none matches. +func addQwenHook(groups []qwenMatcherGroup, hook qwenHookEntry, matcher *string) []qwenMatcherGroup { + for i, group := range groups { + if matchersEqual(group.Matcher, matcher) { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, qwenMatcherGroup{Matcher: matcher, Hooks: []qwenHookEntry{hook}}) +} + +func matchersEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/backend/internal/adapters/agent/qwen/qwen.go b/backend/internal/adapters/agent/qwen/qwen.go new file mode 100644 index 0000000..5784f9d --- /dev/null +++ b/backend/internal/adapters/agent/qwen/qwen.go @@ -0,0 +1,259 @@ +// Package qwen implements the Qwen Code agent adapter: launching new sessions, +// resuming hook-tracked sessions, installing workspace-local native hooks, and +// reading hook-derived session info. +// +// Qwen Code (github.com/QwenLM/qwen-code) is a fork of Google's gemini-cli, so +// it inherits gemini-cli-shaped flags: `-p/--prompt` (or a positional prompt) +// for the headless one-shot prompt, `--approval-mode {plan,default,auto-edit, +// auto,yolo}` for permissions, and `-r/--resume ` to continue a specific +// session. It also has a native Claude-Code-shaped hook system configured in +// `.qwen/settings.json` (top-level "hooks" key, event arrays of matcher groups +// with command hooks), and emits a `session_id` in every hook payload — so AO +// captures native session identity and activity from those hooks rather than +// from transcript/cache scans. +package qwen + +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 ( + qwenTitleMetadataKey = "title" + qwenSummaryMetadataKey = "summary" +) + +// Plugin is the Qwen Code 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 Qwen Code 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: "qwen", + Name: "Qwen Code", + Description: "Run Qwen Code worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Qwen Code exposes none 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 Qwen Code session: the +// approval-mode flag, optional system-prompt instructions, and the initial +// prompt (passed via `-p` so a leading "-" is not read as a flag). Prompt is +// delivered in-command. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.qwenBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.SystemPrompt != "" { + cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "-p", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Qwen Code receives its prompt in the +// launch command itself (via -p). +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 +} + +// GetRestoreCommand rebuilds the argv that continues an existing Qwen Code +// session: `qwen [--approval-mode ] -r `. ok is false when +// the hook-derived native session id has not landed yet, so callers can fall +// back to fresh launch behavior. Note: ports.RestoreConfig carries no Prompt. +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.qwenBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 6) + cmd = append(cmd, binary) + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "-r", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Qwen Code hook-derived metadata. Metadata is +// intentionally nil for Qwen: callers get the normalized fields directly. +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 + } + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[qwenTitleMetadataKey], + Summary: session.Metadata[qwenSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveQwenBinary returns the path to the qwen binary on this machine, +// searching PATH then a handful of well-known install locations (Homebrew, npm +// global). Returns "qwen" as a last-ditch fallback so callers see a clear +// "command not found" rather than an empty argv. +func ResolveQwenBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"qwen.cmd", "qwen.exe", "qwen"} { + path, err := exec.LookPath(name) + if 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", "qwen.cmd"), + filepath.Join(appData, "npm", "qwen.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "qwen", nil + } + + if path, err := exec.LookPath("qwen"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/qwen", + "/opt/homebrew/bin/qwen", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "qwen"), + filepath.Join(home, ".npm", "bin", "qwen"), + filepath.Join(home, ".local", "bin", "qwen"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "qwen", nil +} + +func (p *Plugin) qwenBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveQwenBinary(ctx) + if err != nil { + return "", err + } + p.resolvedBinary = binary + return binary, nil +} + +// appendApprovalFlags maps AO's four permission modes onto Qwen Code's +// `--approval-mode` choices (plan|default|auto-edit|auto|yolo). Default emits no +// flag so Qwen resolves its starting mode from the user's own config. +func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { + switch normalizePermissionMode(permissions) { + case ports.PermissionModeDefault: + // No flag: defer to the user's Qwen Code config/default behavior. + case ports.PermissionModeAcceptEdits: + *cmd = append(*cmd, "--approval-mode", "auto-edit") + case ports.PermissionModeAuto: + *cmd = append(*cmd, "--approval-mode", "auto") + case ports.PermissionModeBypassPermissions: + *cmd = append(*cmd, "--approval-mode", "yolo") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + return ports.PermissionModeDefault + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/backend/internal/adapters/agent/qwen/qwen_test.go b/backend/internal/adapters/agent/qwen/qwen_test.go new file mode 100644 index 0000000..034cfc0 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/qwen_test.go @@ -0,0 +1,442 @@ +package qwen + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPrompt: "be terse", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "qwen", + "--approval-mode", "yolo", + "--append-system-prompt", "be terse", + "-p", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + want []string + notExpected string + }{ + { + name: "default", + permission: ports.PermissionModeDefault, + notExpected: "--approval-mode", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--approval-mode", "auto-edit"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--approval-mode", "auto"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--approval-mode", "yolo"}, + }, + { + name: "empty falls back to default", + permission: "", + notExpected: "--approval-mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: tt.permission, + }) + if err != nil { + t.Fatal(err) + } + if len(tt.want) > 0 && !containsSubsequence(cmd, tt.want) { + t.Fatalf("command %#v does not contain %#v", cmd, tt.want) + } + if tt.notExpected != "" && contains(cmd, tt.notExpected) { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) + if err != nil { + t.Fatal(err) + } + if got != ports.PromptDeliveryInCommand { + t.Fatalf("unexpected strategy: %q", got) + } +} + +func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + spec, err := plugin.GetConfigSpec(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(spec.Fields) != 0 { + t.Fatalf("unexpected config fields: %#v", spec.Fields) + } +} + +func TestContextCancellationIsHonored(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: want error from cancelled context") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: want error from cancelled context") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: t.TempDir()}); err == nil { + t.Fatal("GetAgentHooks: want error from cancelled context") + } + if err := plugin.UninstallHooks(ctx, t.TempDir()); err == nil { + t.Fatal("UninstallHooks: want error from cancelled context") + } + if _, err := plugin.AreHooksInstalled(ctx, t.TempDir()); err == nil { + t.Fatal("AreHooksInstalled: want error from cancelled context") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: want error from cancelled context") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: want error from cancelled context") + } +} + +func TestGetAgentHooksInstallsQwenHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + workspace := t.TempDir() + settingsDir := filepath.Join(workspace, ".qwen") + if err := os.MkdirAll(settingsDir, 0o755); err != nil { + t.Fatal(err) + } + settingsPath := filepath.Join(settingsDir, "settings.json") + // Pre-seed an unrelated top-level setting and a user-owned Stop hook; both + // must be preserved. + existing := `{"theme":"dark","hooks":{"Stop":[{"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + cfg := ports.WorkspaceHookConfig{ + DataDir: t.TempDir(), + SessionID: "sess-1", + WorkspacePath: workspace, + } + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + // A second install must not duplicate AO hook commands. + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + + // Unrelated top-level setting survives. + var top map[string]json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + t.Fatal(err) + } + if string(top["theme"]) != `"dark"` { + t.Fatalf("unrelated top-level setting not preserved: %s", top["theme"]) + } + + var config qwenHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + if config.Hooks == nil { + t.Fatalf("hooks config missing hooks object: %#v", config) + } + for _, spec := range qwenManagedHooks { + entries := config.Hooks[spec.Event] + if count := countQwenHookCommand(entries, spec.Command); count != 1 { + t.Fatalf("%s command count = %d, want 1 in %#v", spec.Event, count, entries) + } + } + // User-owned Stop hook survives. + if countQwenHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 { + t.Fatalf("existing Stop hook was not preserved: %#v", config.Hooks["Stop"]) + } + // SessionStart lands under the "startup" matcher. + assertStartupMatcher(t, config.Hooks["SessionStart"]) +} + +func assertStartupMatcher(t *testing.T, groups []qwenMatcherGroup) { + t.Helper() + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == qwenHookCommandPrefix+"session-start" { + if group.Matcher == nil || *group.Matcher != "startup" { + t.Fatalf("session-start hook not under 'startup' matcher: %#v", group) + } + return + } + } + } + t.Fatalf("session-start hook not found: %#v", groups) +} + +func TestUninstallHooksRemovesQwenHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + workspace := t.TempDir() + settingsPath := filepath.Join(workspace, ".qwen", "settings.json") + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own Stop hook; it must survive uninstall. + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"custom stop hook","timeout":3}]}]}}` + if err := os.WriteFile(settingsPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || !installed { + t.Fatalf("AreHooksInstalled after install = (%v, %v), want (true, nil)", installed, err) + } + + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled after uninstall = (%v, %v), want (false, nil)", installed, err) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + var config qwenHookFile + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, spec := range qwenManagedHooks { + if got := countQwenHookCommand(config.Hooks[spec.Event], spec.Command); got != 0 { + t.Fatalf("%s command %q count = %d after uninstall, want 0", spec.Event, spec.Command, got) + } + } + if countQwenHookCommand(config.Hooks["Stop"], "custom stop hook") != 1 { + t.Fatalf("user Stop hook not preserved: %#v", config.Hooks["Stop"]) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "sess-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "qwen", + "--approval-mode", "auto", + "-r", "sess-123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + cases := []struct { + name string + ref ports.SessionRef + }{ + {"empty session ref", ports.SessionRef{}}, + {"empty metadata", ports.SessionRef{Metadata: map[string]string{}}}, + {"blank agent session metadata", ports.SessionRef{Metadata: map[string]string{ports.MetadataKeyAgentSessionID: " "}}}, + {"workspace path only", ports.SessionRef{WorkspacePath: "/some/path"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: tc.ref, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if cmd != nil { + t.Fatalf("cmd = %#v, want nil", cmd) + } + }) + } +} + +func TestSessionInfoReadsHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "sess-123", + qwenTitleMetadataKey: "Fix login redirect", + qwenSummaryMetadataKey: "Updated the auth callback and tests.", + "ignored": "not returned", + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatalf("ok = false, want true") + } + if info.AgentSessionID != "sess-123" { + t.Fatalf("AgentSessionID = %q, want native id", info.AgentSessionID) + } + if info.Title != "Fix login redirect" { + t.Fatalf("Title = %q, want hook title", info.Title) + } + if info.Summary != "Updated the auth callback and tests." { + t.Fatalf("Summary = %q, want hook summary", info.Summary) + } + if info.Metadata != nil { + t.Fatalf("Metadata = %#v, want nil for Qwen", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "qwen"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{}, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if ok { + t.Fatalf("ok = true, want false") + } + if !reflect.DeepEqual(info, ports.SessionInfo{}) { + t.Fatalf("info = %#v, want zero value", info) + } +} + +func TestDeriveActivityState(t *testing.T) { + tests := []struct { + event string + wantState domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tt := range tests { + t.Run(tt.event, func(t *testing.T) { + state, ok := DeriveActivityState(tt.event, nil) + if state != tt.wantState || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) + } + }) + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func containsSubsequence(values []string, needle []string) bool { + if len(needle) == 0 { + return true + } + + for start := range values { + if start+len(needle) > len(values) { + return false + } + ok := true + for offset, want := range needle { + if values[start+offset] != want { + ok = false + break + } + } + if ok { + return true + } + } + + return false +} + +func countQwenHookCommand(entries []qwenMatcherGroup, command string) int { + count := 0 + for _, entry := range entries { + for _, hook := range entry.Hooks { + if hook.Command == command { + count++ + } + } + } + return count +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 317cd77..fdf3a47 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -9,7 +9,12 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "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/copilot" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kimi" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -23,6 +28,11 @@ func Constructors() []adapters.Adapter { claudecode.New(), codex.New(), opencode.New(), + grok.New(), + cursor.New(), + qwen.New(), + copilot.New(), + kimi.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 963aa17..9552e1f 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -93,6 +93,11 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessClaudeCode, "claude-code"}, {domain.HarnessCodex, "codex"}, {domain.HarnessOpenCode, "opencode"}, + {domain.HarnessGrok, "grok"}, + {domain.HarnessCursor, "cursor"}, + {domain.HarnessQwen, "qwen"}, + {domain.HarnessCopilot, "copilot"}, + {domain.HarnessKimi, "kimi"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)