diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index d124bbf..4486e8b 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -12,8 +12,10 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" "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/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "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" ) @@ -37,6 +39,8 @@ var Derivers = map[string]DeriveFunc{ "codex": codex.DeriveActivityState, "droid": droid.DeriveActivityState, "agy": agy.DeriveActivityState, + "cursor": cursor.DeriveActivityState, + "qwen": qwen.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when 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/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..667b31a --- /dev/null +++ b/backend/internal/adapters/agent/qwen/hooks.go @@ -0,0 +1,374 @@ +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.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 3eb181b..e6f437f 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -13,9 +13,11 @@ 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/crush" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/grok" "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" ) @@ -35,6 +37,8 @@ func Constructors() []adapters.Adapter { agy.New(), crush.New(), aider.New(), + cursor.New(), + qwen.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index d1a4d2c..d3e1be9 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -99,6 +99,8 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessAgy, "agy"}, {domain.HarnessCrush, "crush"}, {domain.HarnessAider, "aider"}, + {domain.HarnessCursor, "cursor"}, + {domain.HarnessQwen, "qwen"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)