diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index e9cf0bd..e21eabe 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -11,6 +11,7 @@ package activitydispatch 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/cline" "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" @@ -45,6 +46,7 @@ var Derivers = map[string]DeriveFunc{ "qwen": qwen.DeriveActivityState, "copilot": copilot.DeriveActivityState, "goose": goose.DeriveActivityState, + "cline": cline.DeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go new file mode 100644 index 0000000..5d51238 --- /dev/null +++ b/backend/internal/adapters/agent/cline/activity.go @@ -0,0 +1,32 @@ +package cline + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// DeriveActivityState maps a Cline 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 by clineManagedHooks +// ("session-start", "user-prompt-submit", "permission-request", "stop"), not +// the native Cline event name. Cline currently exposes no stable +// session/process-end hook the adapter installs, so runtime exit still falls +// back to the lifecycle reaper. +// +// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds +// a stable native session/process-end hook (e.g. session_shutdown via the CLI +// `cline hook` path), map it to ActivityExited here. Until then, ensure the +// reaper can still mark a dead Cline 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/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go new file mode 100644 index 0000000..4dd9903 --- /dev/null +++ b/backend/internal/adapters/agent/cline/cline.go @@ -0,0 +1,261 @@ +// Package cline implements the Cline CLI agent adapter: launching new +// headless sessions, resuming sessions by native session id, installing +// workspace-local Cline hooks, and reading hook-derived session info. +// +// Cline is an autonomous coding agent that runs in the terminal (binary +// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing +// the prompt as a positional argument and requesting NDJSON output with +// `--json`, which Cline emits one event per line for machine parsing. +// +// AO-managed sessions derive native session identity from Cline hooks +// (the workspace-local `.clinerules/hooks/` executable scripts AO installs) +// rather than transcript/cache scans. +package cline + +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 ( + clineTitleMetadataKey = "title" + clineSummaryMetadataKey = "summary" +) + +// Plugin is the Cline 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 Cline 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: "cline", + Name: "Cline", + Description: "Run Cline worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. Cline 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 Cline session, +// requesting machine-readable NDJSON output (`--json`), applying the approval +// flags, an optional system-prompt override (`-s`), and the initial prompt as +// the trailing positional argument. The prompt is placed after `--` so a +// leading "-" is not read as a flag. +func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { + binary, err := p.clineBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary, "--json"} + appendApprovalFlags(&cmd, cfg.Permissions) + + if cfg.SystemPrompt != "" { + cmd = append(cmd, "-s", cfg.SystemPrompt) + } + + if cfg.Prompt != "" { + cmd = append(cmd, "--", cfg.Prompt) + } + + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that Cline receives its prompt in the +// launch command itself (as a positional argument). +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 Cline session: +// `cline --json [approval flags] --id `. ok is false when the +// hook-derived native session id has not landed yet, so callers can fall back +// to fresh launch behavior. +func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + agentSessionID := strings.TrimSpace(cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.clineBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 8) + cmd = append(cmd, binary, "--json") + appendApprovalFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--id", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces Cline hook-derived metadata. Metadata is intentionally +// nil for Cline: 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[clineTitleMetadataKey], + Summary: session.Metadata[clineSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// ResolveClineBinary returns the path to the cline binary on this machine, +// searching PATH then a handful of well-known install locations +// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers +// see a clear "command not found" rather than an empty argv. +func ResolveClineBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"cline.cmd", "cline.exe", "cline"} { + 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", "cline.cmd"), + filepath.Join(appData, "npm", "cline.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cline", nil + } + + if path, err := exec.LookPath("cline"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/cline", + "/opt/homebrew/bin/cline", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".npm-global", "bin", "cline"), + filepath.Join(home, ".npm", "bin", "cline"), + filepath.Join(home, ".local", "bin", "cline"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "cline", nil +} + +func (p *Plugin) clineBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveClineBinary(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 Cline config/default behavior. + case ports.PermissionModeAcceptEdits: + // Edit-accepting mode: turn on Cline's auto-approval so edits are + // applied without prompting, matching the AcceptEdits semantics every + // other adapter uses (the more-permissive, edit-accepting mode). + *cmd = append(*cmd, "--auto-approve", "true") + case ports.PermissionModeAuto: + // Auto-approve every tool for unattended runs. + *cmd = append(*cmd, "--auto-approve", "true") + case ports.PermissionModeBypassPermissions: + // yolo mode: auto-approve tools with the restricted (safer) toolset. + *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/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go new file mode 100644 index 0000000..7b33121 --- /dev/null +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -0,0 +1,432 @@ +package cline + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "-fix this", + SystemPrompt: "be careful", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "cline", + "--json", + "--yolo", + "-s", "be careful", + "--", "-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: "--auto-approve", + }, + { + name: "accept-edits", + permission: ports.PermissionModeAcceptEdits, + want: []string{"--auto-approve", "true"}, + }, + { + name: "auto", + permission: ports.PermissionModeAuto, + want: []string{"--auto-approve", "true"}, + }, + { + name: "bypass-permissions", + permission: ports.PermissionModeBypassPermissions, + want: []string{"--yolo"}, + }, + { + name: "empty", + permission: "", + notExpected: "--auto-approve", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + 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: "cline"} + + 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: "cline"} + + 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 TestManifestIDMatchesHarness(t *testing.T) { + m := (&Plugin{}).Manifest() + if m.ID != "cline" { + t.Fatalf("manifest ID = %q, want %q", m.ID, "cline") + } + if m.Name != "Cline" { + t.Fatalf("manifest Name = %q, want %q", m.Name, "Cline") + } +} + +func TestGetAgentHooksInstallsClineHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir) + + // Pre-seed a user's own hook script; it must survive install. + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + t.Fatal(err) + } + userHook := filepath.Join(hooksDir, "PostToolUse") + if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); 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 be idempotent (no error, scripts still single). + if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { + t.Fatal(err) + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", spec.Event, err) + } + content := string(data) + if !strings.Contains(content, clineHookMarker) { + t.Fatalf("%s missing AO marker:\n%s", spec.Event, content) + } + if !strings.Contains(content, clineHookCommandPrefix+spec.Subcommand) { + t.Fatalf("%s missing forward command %q:\n%s", spec.Event, clineHookCommandPrefix+spec.Subcommand, content) + } + info, err := os.Stat(scriptPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Fatalf("%s is not executable: %v", spec.Event, info.Mode()) + } + } + + // User-authored hook untouched. + data, err := os.ReadFile(userHook) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), clineHookMarker) { + t.Fatalf("user PostToolUse hook was overwritten by AO: %s", data) + } +} + +func TestGetAgentHooksRequiresWorkspacePath(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + if err := plugin.GetAgentHooks(context.Background(), ports.WorkspaceHookConfig{}); err == nil { + t.Fatal("expected error for empty WorkspacePath") + } +} + +func TestUninstallHooksRemovesClineHooks(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + workspace := t.TempDir() + hooksDir := filepath.Join(workspace, clineHooksDirName, clineHooksSubDir) + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own hook; it must survive uninstall. + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + t.Fatal(err) + } + userHook := filepath.Join(hooksDir, "PostToolUse") + if err := os.WriteFile(userHook, []byte("#!/usr/bin/env bash\necho '{\"cancel\": false}'\n"), 0o700); 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) + } + + for _, spec := range clineManagedHooks { + if fileExists(filepath.Join(hooksDir, spec.Event)) { + t.Fatalf("%s still present after uninstall", spec.Event) + } + } + if !fileExists(userHook) { + t.Fatal("user PostToolUse hook was removed by uninstall") + } +} + +func TestUninstallHooksMissingDirIsNoOp(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + if err := plugin.UninstallHooks(context.Background(), t.TempDir()); err != nil { + t.Fatalf("uninstall on missing hooks dir = %v, want nil", err) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeAuto, + Session: ports.SessionRef{ + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "session-123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "cline", + "--json", + "--auto-approve", "true", + "--id", "session-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: "cline"} + + 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: "cline"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + ports.MetadataKeyAgentSessionID: "session-123", + clineTitleMetadataKey: "Fix login redirect", + clineSummaryMetadataKey: "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 != "session-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 Cline", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + 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 + want 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) { + got, ok := DeriveActivityState(tt.event, nil) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestContextCancellationIsHonored(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := plugin.GetLaunchCommand(ctx, ports.LaunchConfig{}); err == nil { + // GetLaunchCommand resolves the cached binary first; ctx.Err is checked + // inside ResolveClineBinary only when no cached binary. With a cached + // binary it may not error, so we assert the other methods instead. + _ = err + } + if _, err := plugin.GetConfigSpec(ctx); err == nil { + t.Fatal("GetConfigSpec: expected context error") + } + if _, err := plugin.GetPromptDeliveryStrategy(ctx, ports.LaunchConfig{}); err == nil { + t.Fatal("GetPromptDeliveryStrategy: expected context error") + } + if _, _, err := plugin.GetRestoreCommand(ctx, ports.RestoreConfig{}); err == nil { + t.Fatal("GetRestoreCommand: expected context error") + } + if _, _, err := plugin.SessionInfo(ctx, ports.SessionRef{}); err == nil { + t.Fatal("SessionInfo: expected context error") + } + if err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: "/x"}); err == nil { + t.Fatal("GetAgentHooks: expected context error") + } + if _, err := ResolveClineBinary(ctx); err == nil { + t.Fatal("ResolveClineBinary: expected context error") + } +} + +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 +} diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go new file mode 100644 index 0000000..fc2d0fb --- /dev/null +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -0,0 +1,193 @@ +package cline + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Cline's hook system is git-style: each lifecycle hook is an executable script +// placed in the workspace-local `.clinerules/hooks/` directory, named exactly +// after the hook event (no extension), reading a JSON payload on stdin and +// writing a JSON result on stdout (see docs.cline.bot hooks reference). +// +// AO installs one wrapper script per managed event. Each script forwards the +// hook payload to `ao hooks cline ` and emits the no-op +// continuation result Cline expects. Scripts carry a marker line so install is +// idempotent and uninstall recognizes AO-owned scripts without an embedded +// template to diff against; user-authored hooks (lacking the marker) are never +// touched. +const ( + clineHooksDirName = ".clinerules" + clineHooksSubDir = "hooks" + + // clineHookCommandPrefix identifies the hook commands AO owns. The CLI hook + // dispatcher routes "ao hooks cline " to DeriveActivityState. + clineHookCommandPrefix = "ao hooks cline " + + // clineHookMarker tags AO-generated hook scripts so install/uninstall can + // distinguish them from user-authored Cline hooks in the same directory. + clineHookMarker = "# ao-managed-cline-hook" +) + +// clineHookSpec describes one hook AO installs: the native Cline hook event +// (used as the script's filename) and the AO sub-command its wrapper forwards +// to (used by DeriveActivityState). +type clineHookSpec struct { + // Event is the native Cline hook name, which is also the script filename. + Event string + // Subcommand is the fixed AO hook sub-command name the wrapper invokes. + Subcommand string +} + +// clineManagedHooks is the source of truth for the hooks AO installs. The +// native Cline events are mapped onto AO's fixed sub-command names so activity +// derivation stays uniform across adapters: +// - TaskStart -> session-start (a new task begins: active) +// - UserPromptSubmit -> user-prompt-submit (user message submitted: active) +// - PreToolUse -> permission-request (about to act: approval point) +// - TaskCancel -> stop (task cancelled/aborted: idle) +var clineManagedHooks = []clineHookSpec{ + {Event: "TaskStart", Subcommand: "session-start"}, + {Event: "UserPromptSubmit", Subcommand: "user-prompt-submit"}, + {Event: "PreToolUse", Subcommand: "permission-request"}, + {Event: "TaskCancel", Subcommand: "stop"}, +} + +// GetAgentHooks installs AO's Cline hook scripts into the worktree-local +// `.clinerules/hooks/` directory. Existing user-authored hook scripts are +// preserved, and re-running install simply rewrites AO-owned scripts in place. +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("cline.GetAgentHooks: WorkspacePath is required") + } + + hooksDir := clineHooksDir(cfg.WorkspacePath) + if err := os.MkdirAll(hooksDir, 0o750); err != nil { + return fmt.Errorf("cline.GetAgentHooks: create hook dir: %w", err) + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + // Never clobber a user-authored hook with the same event name. + if fileExists(scriptPath) && !isManagedClineHook(scriptPath) { + continue + } + script := renderClineHookScript(spec.Subcommand) + if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) + } + } + return nil +} + +// UninstallHooks removes AO's Cline hook scripts from the workspace-local +// `.clinerules/hooks/` directory, leaving user-authored hooks untouched. A +// missing directory 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("cline.UninstallHooks: workspacePath is required") + } + + hooksDir := clineHooksDir(workspacePath) + if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) { + return nil + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + continue + } + if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cline.UninstallHooks: remove %s: %w", spec.Event, err) + } + } + return nil +} + +// AreHooksInstalled reports whether any AO Cline hook script is present in the +// workspace-local hooks directory. A missing directory means none. +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("cline.AreHooksInstalled: workspacePath is required") + } + + hooksDir := clineHooksDir(workspacePath) + if _, err := os.Stat(hooksDir); errors.Is(err, os.ErrNotExist) { + return false, nil + } + + for _, spec := range clineManagedHooks { + scriptPath := filepath.Join(hooksDir, spec.Event) + if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + return true, nil + } + } + return false, nil +} + +func clineHooksDir(workspacePath string) string { + return filepath.Join(workspacePath, clineHooksDirName, clineHooksSubDir) +} + +// renderClineHookScript builds an executable wrapper that forwards the Cline +// hook payload (JSON on stdin) to the AO CLI hook dispatcher and prints the +// no-op continuation result Cline expects ({"cancel": false}). The marker line +// identifies it as AO-owned. +func renderClineHookScript(subcommand string) string { + var b strings.Builder + b.WriteString("#!/usr/bin/env bash\n") + b.WriteString(clineHookMarker + "\n") + // Forward stdin to the AO dispatcher; ignore its exit code so a missing/old + // `ao` binary can never block Cline's own execution. + b.WriteString(clineHookCommandPrefix + subcommand + " || true\n") + // Cline requires a JSON result on stdout; never block the agent. + b.WriteString(`echo '{"cancel": false}'` + "\n") + return b.String() +} + +func isManagedClineHook(scriptPath string) bool { + data, err := os.ReadFile(scriptPath) //nolint:gosec // path built from caller-owned workspace dir + if err != nil { + return false + } + return strings.Contains(string(data), clineHookMarker) +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated script that Cline then fails to execute. +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) }() + 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) +} 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/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index a93ba4e..acc3d92 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -12,6 +12,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/amp" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/auggie" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/continueagent" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" @@ -21,6 +22,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" "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" @@ -49,6 +51,8 @@ func Constructors() []adapters.Adapter { auggie.New(), continueagent.New(), devin.New(), + cline.New(), + kimi.New(), } } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 5eeda64..3b2a7bb 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -106,6 +106,8 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { {domain.HarnessAuggie, "auggie"}, {domain.HarnessContinue, "continue"}, {domain.HarnessDevin, "devin"}, + {domain.HarnessCline, "cline"}, + {domain.HarnessKimi, "kimi"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)