diff --git a/backend/internal/adapters/agent/opencode/assets/ao-activity.ts b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts new file mode 100644 index 000000000..4cd217df9 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/assets/ao-activity.ts @@ -0,0 +1,174 @@ +// agent-orchestrator: managed opencode activity plugin (do not edit) +// +// It maps opencode's native lifecycle events onto AO's three normalized +// activity events: +// session.created -> `ao hooks opencode session-start` +// message.updated / message.part.updated -> `ao hooks opencode user-prompt-submit` +// session.status (status.type == idle) -> `ao hooks opencode stop` +// +// The opencode-native session id (and prompt/model where known) is piped to the +// hook command as JSON on stdin, run with cwd set to the worktree so AO can +// correlate the opencode session to its AO session. Every invocation is +// best-effort and must never crash the user's opencode session: a missing `ao` +// binary is a guarded no-op (`command -v ao`), and spawn exceptions, non-zero +// exit codes, and malformed event payloads are caught and surfaced through +// opencode's structured logger (client.app.log) for diagnosis — never rethrown. +// +// `import type` is erased at runtime by Bun's transpiler, so this loads even +// before opencode has installed @opencode-ai/plugin into the config dir. +import type { Plugin } from "@opencode-ai/plugin" + +export const aoActivity: Plugin = async ({ directory, client }) => { + // ao hooks must never be able to hang opencode: cap each invocation, matching + // the 30s timeout the claude-code and codex hook entries use. + const HOOK_TIMEOUT_MS = 30_000 + // A user message is reported at most twice (see reportUserPrompt): an optional + // early empty report, then an upgrade carrying the prompt text. Maps a message + // id to whether the report we already sent included the prompt text. + const promptReports = new Map() + // message.* events don't carry the session id, so track it from events that do. + let currentSessionID: string | null = null + // The model of the most recent assistant message, forwarded for context. + let currentModel: string | null = null + const messageStore = new Map() + + // Wrap in `sh -c` with a guard so a missing `ao` binary is a silent no-op + // (exit 0) rather than a per-event error in the user's session. + function hookCmd(hookName: string): string[] { + return ["sh", "-c", `if ! command -v ao >/dev/null 2>&1; then exit 0; fi; exec ao hooks opencode ${hookName}`] + } + + // Report a hook failure through opencode's structured logger. Best-effort: the + // log call must itself never throw or reject back into opencode, hence the + // optional chaining + swallowed rejection. + function logHookFailure(hookName: string, detail: string) { + try { + void client?.app + ?.log?.({ body: { service: "ao-activity", level: "error", message: `hook ${hookName} failed: ${detail}` } }) + ?.catch?.(() => {}) + } catch { + // The logger itself is unavailable — nothing more we can safely do. + } + } + + // All hooks are dispatched synchronously (Bun.spawnSync), for two reasons: + // 1. Ordering. An async hook yields the event loop; if opencode does not + // await the handler's promise, a later event (e.g. message.updated -> + // user-prompt-submit) could complete before an in-flight async + // session-start, so AO would see the prompt before the session is + // registered. spawnSync blocks opencode's single-threaded loop until the + // hook returns, so events are reported strictly in dispatch order. + // 2. `opencode run` exits on the idle event, so an async stop hook would be + // killed before completing. + // + // A non-zero exit (the guard makes a missing `ao` exit 0, so this is a real + // `ao hooks` failure) or a spawn exception is logged with its stderr and never + // rethrown, so reporting failures are diagnosable without crashing opencode. + function callHookSync(hookName: string, payload: Record) { + try { + const result = Bun.spawnSync(hookCmd(hookName), { + cwd: directory, + stdin: new TextEncoder().encode(JSON.stringify(payload) + "\n"), + stdout: "ignore", + stderr: "pipe", + timeout: HOOK_TIMEOUT_MS, + }) + if (!result.success) { + const stderr = result.stderr ? new TextDecoder().decode(result.stderr).trim() : "" + logHookFailure(hookName, `exited ${result.exitCode}${stderr ? `: ${stderr}` : ""}`) + } + } catch (err) { + // The spawn itself failed (e.g. no `sh` on PATH). Never propagate. + logHookFailure(hookName, err instanceof Error ? err.message : String(err)) + } + } + + function switchedSession(sessionID: string): boolean { + if (currentSessionID === sessionID) return false + promptReports.clear() + messageStore.clear() + currentModel = null + currentSessionID = sessionID + return true + } + + // Report a user prompt, preferring the one that carries the prompt text. + // message.updated can arrive before message.part.updated with no text, so an + // early empty report must NOT dedup away the later text report — otherwise the + // prompt never reaches AO and title-from-prompt metadata breaks. Therefore: an + // empty report fires at most once (so run-mode flows that omit the text part + // still mark the session active), and a text report fires once and is terminal. + function reportUserPrompt(sessionID: string, messageID: string, prompt: string) { + const hasText = prompt.length > 0 + const reportedWithText = promptReports.get(messageID) + if (reportedWithText) return // already reported with text — terminal + if (reportedWithText === false && !hasText) return // already reported empty; no new info + promptReports.set(messageID, hasText) + callHookSync("user-prompt-submit", { session_id: sessionID, prompt, model: currentModel ?? "" }) + } + + return { + event: async ({ event }) => { + try { + switch (event.type) { + case "session.created": { + const session = (event as any).properties?.info + if (!session?.id) break + if (switchedSession(session.id)) { + callHookSync("session-start", { session_id: session.id }) + } + break + } + + case "message.updated": { + const msg = (event as any).properties?.info + if (!msg) break + if (msg.sessionID && switchedSession(msg.sessionID)) { + callHookSync("session-start", { session_id: msg.sessionID }) + } + if (msg.role === "assistant" && msg.modelID) currentModel = msg.modelID + // Fallback: some `opencode run` flows never deliver message.part.updated + // for the prompt, so start the turn from the user message itself. + if (msg.role === "user") { + messageStore.set(msg.id, msg) + const sessionID = msg.sessionID ?? currentSessionID + if (sessionID) reportUserPrompt(sessionID, msg.id, "") + } + break + } + + case "message.part.updated": { + const part = (event as any).properties?.part + if (!part?.messageID) break + const msg = messageStore.get(part.messageID) + if (msg?.role === "user" && part.type === "text") { + const sessionID = msg.sessionID ?? currentSessionID + const prompt = part.text ?? "" + if (sessionID) reportUserPrompt(sessionID, msg.id, prompt) + if (prompt.length > 0) messageStore.delete(part.messageID) + } + break + } + + case "session.status": { + // session.status fires in both TUI and `opencode run`; session.idle + // is deprecated and not reliably emitted in run mode. + // AO's "stop" hook means "the current turn is idle/finished", not + // "the whole native session has terminated", so multi-turn TUI + // sessions intentionally emit one stop per idle transition. + const props = (event as any).properties + if (props?.status?.type !== "idle") break + const sessionID = props?.sessionID ?? currentSessionID + if (!sessionID) break + callHookSync("stop", { session_id: sessionID, model: currentModel ?? "" }) + break + } + } + } catch (err) { + // A malformed/unexpected event payload must never crash opencode; log + // it (tagged with the event type) for diagnosis and move on. + logHookFailure(`event:${(event as any)?.type ?? "unknown"}`, err instanceof Error ? err.message : String(err)) + } + }, + } +} diff --git a/backend/internal/adapters/agent/opencode/hooks.go b/backend/internal/adapters/agent/opencode/hooks.go new file mode 100644 index 000000000..8e7b1b5ea --- /dev/null +++ b/backend/internal/adapters/agent/opencode/hooks.go @@ -0,0 +1,185 @@ +package opencode + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + _ "embed" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // opencode scans both `.opencode/plugin/` and `.opencode/plugins/` for + // `*.js`/`*.ts` files (see opencode's ConfigPlugin glob + // "{plugin,plugins}/*.{ts,js}"). AO writes the plural `plugins/`, matching + // the directory the upstream opencode tooling (and the entire-cli reference + // integration) uses. + opencodePluginDirName = ".opencode" + opencodePluginSubDir = "plugins" + + // opencodePluginFileName is the AO-owned plugin file. AO fully owns this + // filename: install overwrites it and uninstall deletes it (guarded by the + // sentinel), so user-authored plugins in other files are never touched. + // It is TypeScript (opencode runs on Bun); the file's only import is a + // type-only import, which Bun erases at runtime. + opencodePluginFileName = "ao-activity.ts" + + // opencodePluginSentinel marks the file as AO-managed. AreHooksInstalled and + // UninstallHooks key off it so AO never deletes a user file that happens to + // share the name. It must appear verbatim in the embedded plugin source. + opencodePluginSentinel = "agent-orchestrator: managed opencode activity plugin" + + // opencodeHookCommandPrefix identifies the hook commands AO owns. The + // embedded plugin shells `ao hooks opencode `; this prefix is the + // shared contract with the (forthcoming) `ao hooks` CLI and is asserted by + // tests so the plugin can't silently drift away from it. + opencodeHookCommandPrefix = "ao hooks opencode " +) + +// opencodePluginSource is the AO-managed opencode plugin, embedded so it ships +// inside the binary and is written verbatim into a session's worktree on hook +// install. It is a real, lintable source file under assets/ rather than a Go +// string literal because it is opencode plugin source code, not a data +// structure AO assembles (the way it builds Codex/Claude hook JSON). +// +//go:embed assets/ao-activity.ts +var opencodePluginSource string + +// opencodeManagedEvents are the three normalized activity events the embedded +// plugin reports. They are defined here (not parsed from the file) so tests can +// assert the plugin wires every one via the `ao hooks opencode ` command. +var opencodeManagedEvents = []string{"session-start", "user-prompt-submit", "stop"} + +// GetAgentHooks installs AO's opencode activity plugin into the worktree-local +// .opencode/plugins/ directory. Unlike Claude Code and Codex, opencode has no +// native command-hook config to merge into; its only lifecycle-extensibility +// surface is a JS/TS plugin. AO therefore writes a dedicated, AO-owned plugin +// file. The write is atomic and idempotent: re-installing overwrites AO's own +// file with identical content. It refuses to overwrite a file that is NOT +// AO-managed (no sentinel), so a user plugin that happens to occupy our path is +// never silently destroyed — install fails loudly instead. +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("opencode.GetAgentHooks: WorkspacePath is required") + } + + pluginPath := opencodePluginPath(cfg.WorkspacePath) + // Guard against clobbering a user file at our path: overwrite only when the + // target is absent or already AO-managed. A foreign file is a loud error, + // not silent data loss (uninstall is sentinel-guarded the same way). + if _, err := os.Stat(pluginPath); err == nil { + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("opencode.GetAgentHooks: %w", err) + } + if !managed { + return fmt.Errorf("opencode.GetAgentHooks: refusing to overwrite non-AO file at %s — move it so AO can install its plugin", pluginPath) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("opencode.GetAgentHooks: stat plugin: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { + return fmt.Errorf("opencode.GetAgentHooks: create plugin dir: %w", err) + } + if err := atomicWriteFile(pluginPath, []byte(opencodePluginSource), 0o600); err != nil { + return fmt.Errorf("opencode.GetAgentHooks: write plugin: %w", err) + } + return nil +} + +// UninstallHooks removes AO's opencode plugin from the workspace-local +// .opencode/plugins/ directory. It deletes the file only when it carries the AO +// sentinel, so a user file that happens to share the name is left in place. 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("opencode.UninstallHooks: workspacePath is required") + } + + pluginPath := opencodePluginPath(workspacePath) + managed, err := isAOManagedPlugin(pluginPath) + if err != nil { + return fmt.Errorf("opencode.UninstallHooks: %w", err) + } + if !managed { + return nil + } + if err := os.Remove(pluginPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("opencode.UninstallHooks: remove plugin: %w", err) + } + return nil +} + +// AreHooksInstalled reports whether AO's opencode plugin is present in the +// workspace-local plugin dir. A missing file, or a same-named file without the +// AO sentinel, 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("opencode.AreHooksInstalled: workspacePath is required") + } + managed, err := isAOManagedPlugin(opencodePluginPath(workspacePath)) + if err != nil { + return false, fmt.Errorf("opencode.AreHooksInstalled: %w", err) + } + return managed, nil +} + +func opencodePluginPath(workspacePath string) string { + return filepath.Join(workspacePath, opencodePluginDirName, opencodePluginSubDir, opencodePluginFileName) +} + +// isAOManagedPlugin reports whether the file at path exists and carries the AO +// sentinel. A missing file yields (false, nil). +func isAOManagedPlugin(path string) (bool, error) { + data, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + return strings.Contains(string(data), opencodePluginSentinel), nil +} + +// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- +// write can't leave a truncated plugin file that opencode then fails to import +// (silently disabling activity reporting). +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go new file mode 100644 index 000000000..377f1bde3 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -0,0 +1,263 @@ +// Package opencode implements the opencode (sst/opencode) agent adapter: +// launching new TUI sessions, resuming sessions by native id, installing a +// workspace-local activity plugin, and reading plugin-derived session info. +// +// opencode differs from Claude Code and Codex in two ways AO has to bridge: +// - It has no native command-hook config (no settings.local.json / hooks.json +// equivalent). Its only lifecycle-extensibility surface is a JS/TS plugin +// loaded from .opencode/plugins/, so GetAgentHooks installs an AO-owned +// plugin file (see hooks.go) instead of merging JSON. +// - Its CLI exposes only one approval flag (--dangerously-skip-permissions) +// and no system-prompt flag, so the graduated permission modes and the +// system prompt are deferred to opencode's own config. +// +// AO-managed sessions derive native session identity and display metadata from +// the opencode plugin's reported events, mirroring the Codex adapter. +package opencode + +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 is the registry id and the value users pass to + // `ao spawn --agent`. It matches domain.HarnessOpenCode. + adapterID = "opencode" + + // Normalized session-metadata keys the opencode plugin persists into the AO + // session store and SessionInfo reads back. Shared vocabulary with the Codex + // and Claude Code adapters so the dashboard treats every agent uniformly. + opencodeAgentSessionIDMetadataKey = "agentSessionId" + opencodeTitleMetadataKey = "title" + opencodeSummaryMetadataKey = "summary" +) + +// Plugin is the opencode 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 opencode 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: "opencode", + Description: "Run opencode worker sessions.", + Version: "0.0.1", + Capabilities: []adapters.Capability{ + adapters.CapabilityAgent, + }, + } +} + +// GetConfigSpec reports the agent-specific config keys. opencode exposes none +// yet: model and agent selection are read from opencode's own config +// (opencode.json / ~/.config/opencode), exactly as a normal launch. +func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + if err := ctx.Err(); err != nil { + return ports.ConfigSpec{}, err + } + return ports.ConfigSpec{}, nil +} + +// GetLaunchCommand builds the argv to start a new interactive opencode session. +// Shape: +// +// opencode [--dangerously-skip-permissions] [--prompt ] +// +// The session runs in the worktree (cwd is set by the runtime, as for Claude +// Code and Codex). opencode has no CLI flag to set a system prompt, so +// cfg.SystemPrompt / SystemPromptFile are intentionally ignored here — opencode +// resolves instructions from its own config and AGENTS.md rules. The initial +// task prompt is delivered via --prompt (its argument, 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.opencodeBinary(ctx) + if err != nil { + return nil, err + } + + cmd = []string{binary} + appendPermissionFlags(&cmd, cfg.Permissions) + if cfg.Prompt != "" { + cmd = append(cmd, "--prompt", cfg.Prompt) + } + return cmd, nil +} + +// GetPromptDeliveryStrategy reports that opencode receives its prompt in the +// launch command itself (via --prompt). +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 opencode +// session: `opencode [--dangerously-skip-permissions] --session `. +// It re-applies the permission flag (resume otherwise reverts to the configured +// default) but not the prompt, which the session already carries. ok is false +// when the plugin-derived native session id has not landed yet, so callers fall +// back to fresh launch behavior — mirroring the Codex adapter. +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[opencodeAgentSessionIDMetadataKey]) + if agentSessionID == "" { + return nil, false, nil + } + + binary, err := p.opencodeBinary(ctx) + if err != nil { + return nil, false, err + } + + cmd = make([]string, 0, 4) + cmd = append(cmd, binary) + appendPermissionFlags(&cmd, cfg.Permissions) + cmd = append(cmd, "--session", agentSessionID) + return cmd, true, nil +} + +// SessionInfo surfaces opencode plugin-derived metadata. Metadata is +// intentionally nil for opencode: callers get the normalized fields directly, +// matching the Codex adapter. +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[opencodeAgentSessionIDMetadataKey], + Title: session.Metadata[opencodeTitleMetadataKey], + Summary: session.Metadata[opencodeSummaryMetadataKey], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false, nil + } + return info, true, nil +} + +// appendPermissionFlags maps AO's permission modes onto opencode's single +// approval flag. opencode exposes only --dangerously-skip-permissions (no +// graduated accept-edits/auto modes), so: +// - bypass-permissions → --dangerously-skip-permissions +// - default / accept-edits / auto → no flag. opencode resolves approvals from +// its own `permission` config exactly as a normal launch. +func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { + if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions { + *cmd = append(*cmd, "--dangerously-skip-permissions") + } +} + +func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { + switch mode { + case ports.PermissionModeDefault, + ports.PermissionModeAcceptEdits, + ports.PermissionModeAuto, + ports.PermissionModeBypassPermissions: + return mode + default: + // Empty or unrecognized: defer to opencode's own config (no flag). + return ports.PermissionModeDefault + } +} + +// ResolveOpenCodeBinary returns the path to the opencode binary on this machine, +// searching PATH then a handful of well-known install locations (the install +// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a +// last-ditch fallback so callers see a clear "command not found" rather than an +// empty argv. +func ResolveOpenCodeBinary(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + for _, name := range []string{"opencode.cmd", "opencode.exe", "opencode"} { + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + candidates := []string{} + if appData := os.Getenv("APPDATA"); appData != "" { + candidates = append(candidates, + filepath.Join(appData, "npm", "opencode.cmd"), + filepath.Join(appData, "npm", "opencode.exe"), + ) + } + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + return "opencode", nil + } + + if path, err := exec.LookPath("opencode"); err == nil && path != "" { + return path, nil + } + + candidates := []string{ + "/usr/local/bin/opencode", + "/opt/homebrew/bin/opencode", + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + filepath.Join(home, ".opencode", "bin", "opencode"), + filepath.Join(home, ".npm", "bin", "opencode"), + ) + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + } + + return "opencode", nil +} + +func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { + p.binaryMu.Lock() + defer p.binaryMu.Unlock() + + if p.resolvedBinary != "" { + return p.resolvedBinary, nil + } + + binary, err := ResolveOpenCodeBinary(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/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go new file mode 100644 index 000000000..ba73297c1 --- /dev/null +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -0,0 +1,377 @@ +package opencode + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGetLaunchCommandBuildsArgv(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + 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) + } + + // opencode has no system-prompt flag, so SystemPrompt/SystemPromptFile are + // dropped; the prompt is delivered via --prompt. + want := []string{ + "opencode", + "--dangerously-skip-permissions", + "--prompt", "-fix this", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { + tests := []struct { + name string + permission ports.PermissionMode + wantFlag bool + notExpected string + }{ + {name: "default", permission: ports.PermissionModeDefault, notExpected: "--dangerously-skip-permissions"}, + {name: "accept-edits", permission: ports.PermissionModeAcceptEdits, notExpected: "--dangerously-skip-permissions"}, + {name: "auto", permission: ports.PermissionModeAuto, notExpected: "--dangerously-skip-permissions"}, + {name: "bypass-permissions", permission: ports.PermissionModeBypassPermissions, wantFlag: true}, + {name: "empty", permission: "", notExpected: "--dangerously-skip-permissions"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{Permissions: tt.permission}) + if err != nil { + t.Fatal(err) + } + has := contains(cmd, "--dangerously-skip-permissions") + if tt.wantFlag && !has { + t.Fatalf("command %#v missing --dangerously-skip-permissions", cmd) + } + if tt.notExpected != "" && has { + t.Fatalf("command %#v contains %q", cmd, tt.notExpected) + } + }) + } +} + +func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + 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: "opencode"} + + 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 TestGetAgentHooksInstallsPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + + // A user's own plugin in the same dir must survive AO's install untouched. + pluginDir := filepath.Dir(opencodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + userBody := []byte("export const userPlugin = async () => ({})\n") + if err := os.WriteFile(userPlugin, userBody, 0o644); err != nil { + t.Fatal(err) + } + + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + if err := plugin.GetAgentHooks(ctx, cfg); err != nil { + t.Fatal(err) + } + // A second install must be idempotent (overwrite with identical content). + 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) + } + + data, err := os.ReadFile(opencodePluginPath(workspace)) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, opencodePluginSentinel) { + t.Fatalf("installed plugin missing AO sentinel:\n%s", body) + } + // Every normalized activity event must be wired via `ao hooks opencode `. + for _, event := range opencodeManagedEvents { + want := opencodeHookCommandPrefix + event + if !strings.Contains(body, want) { + t.Fatalf("installed plugin missing hook command %q:\n%s", want, body) + } + } + // The opencode-native lifecycle events the plugin subscribes to. Stop maps + // to session.status(idle) — NOT the deprecated session.idle — and the user + // prompt is detected from message.updated/message.part.updated. + for _, marker := range []string{"session.created", "message.updated", "message.part.updated", "session.status"} { + if !strings.Contains(body, marker) { + t.Fatalf("installed plugin missing opencode event %q:\n%s", marker, body) + } + } + // Guard against regressing back to subscribing to the deprecated/unreliable + // session.idle event (the quoted event string is how a `case` would name it; + // the explanatory comment mentions it unquoted, which is fine). + if strings.Contains(body, `"session.idle"`) { + t.Fatalf("plugin subscribes to deprecated session.idle; use session.status(idle):\n%s", body) + } + // A hung `ao hooks` call must not block opencode forever, so each spawn is + // time-boxed (parity with the claude/codex 30s hook timeout). + if !strings.Contains(body, "timeout:") { + t.Fatalf("plugin spawn has no timeout; a hung hook would block opencode:\n%s", body) + } + + // The user's plugin is untouched. + got, err := os.ReadFile(userPlugin) + if err != nil { + t.Fatalf("user plugin removed by install: %v", err) + } + if !reflect.DeepEqual(got, userBody) { + t.Fatalf("user plugin modified by install: %q", got) + } +} + +func TestGetAgentHooksRefusesToClobberForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's exact path must NOT be silently overwritten. + pluginPath := opencodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + err := plugin.GetAgentHooks(ctx, ports.WorkspaceHookConfig{WorkspacePath: workspace}) + if err == nil { + t.Fatal("GetAgentHooks overwrote a non-AO file; want a loud error") + } + got, readErr := os.ReadFile(pluginPath) + if readErr != nil { + t.Fatalf("foreign file removed by refused install: %v", readErr) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by refused install: %q", got) + } +} + +func TestUninstallHooksRemovesPlugin(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + cfg := ports.WorkspaceHookConfig{DataDir: t.TempDir(), SessionID: "sess-1", WorkspacePath: workspace} + + // Pre-seed a user's own plugin; it must survive uninstall. + pluginDir := filepath.Dir(opencodePluginPath(workspace)) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + userPlugin := filepath.Join(pluginDir, "user.js") + if err := os.WriteFile(userPlugin, []byte("export const userPlugin = async () => ({})\n"), 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) + } + if _, err := os.Stat(opencodePluginPath(workspace)); !os.IsNotExist(err) { + t.Fatalf("AO plugin still present after uninstall: err=%v", err) + } + if _, err := os.Stat(userPlugin); err != nil { + t.Fatalf("user plugin removed by uninstall: %v", err) + } +} + +func TestUninstallHooksLeavesForeignFile(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + workspace := t.TempDir() + ctx := context.Background() + + // A non-AO file occupying AO's filename must NOT be deleted by uninstall. + pluginPath := opencodePluginPath(workspace) + if err := os.MkdirAll(filepath.Dir(pluginPath), 0o755); err != nil { + t.Fatal(err) + } + foreign := []byte("export const notOurs = async () => ({})\n") + if err := os.WriteFile(pluginPath, foreign, 0o644); err != nil { + t.Fatal(err) + } + + if installed, err := plugin.AreHooksInstalled(ctx, workspace); err != nil || installed { + t.Fatalf("AreHooksInstalled on foreign file = (%v, %v), want (false, nil)", installed, err) + } + if err := plugin.UninstallHooks(ctx, workspace); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("foreign file removed by uninstall: %v", err) + } + if !reflect.DeepEqual(got, foreign) { + t.Fatalf("foreign file modified by uninstall: %q", got) + } +} + +func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{ + Permissions: ports.PermissionModeBypassPermissions, + Session: ports.SessionRef{ + Metadata: map[string]string{opencodeAgentSessionIDMetadataKey: "ses_abc123"}, + }, + }) + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + if !ok { + t.Fatal("ok = false, want true") + } + want := []string{ + "opencode", + "--dangerously-skip-permissions", + "--session", "ses_abc123", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd) + } +} + +func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + 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{opencodeAgentSessionIDMetadataKey: " "}}}, + {"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.PermissionModeDefault, + 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: "opencode"} + + info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ + WorkspacePath: "/some/path", + Metadata: map[string]string{ + opencodeAgentSessionIDMetadataKey: "ses_abc123", + opencodeTitleMetadataKey: "Fix login redirect", + opencodeSummaryMetadataKey: "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 != "ses_abc123" { + 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 opencode", info.Metadata) + } +} + +func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { + plugin := &Plugin{resolvedBinary: "opencode"} + + 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 contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 69aae5740..84464376a 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -9,6 +9,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/workspace/gitworktree" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -118,7 +119,7 @@ func newSessionMessenger(store *sqlite.Store, runtime runtimeMessageSender, _ *s // empty/duplicate id — a programmer error, not a runtime condition. func buildAgentRegistry() (*adapters.Registry, error) { reg := adapters.NewRegistry() - for _, a := range []adapters.Adapter{claudecode.New(), codex.New()} { + for _, a := range []adapters.Adapter{claudecode.New(), codex.New(), opencode.New()} { if err := reg.Register(a); err != nil { return nil, fmt.Errorf("register agent adapter %q: %w", a.Manifest().ID, err) } diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index 0350c3737..93b023e73 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -92,6 +92,7 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) { }{ {domain.HarnessClaudeCode, "claude-code"}, {domain.HarnessCodex, "codex"}, + {domain.HarnessOpenCode, "opencode"}, {"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default } { agent, ok := resolver.Agent(tc.harness)