feat(agent): opencode adapter + activity plugin hooks (#80)
* feat(agent): add opencode adapter + activity plugin hooks Add an opencode (sst/opencode) agent adapter implementing the 6-method ports.Agent interface and register it in the daemon's agent resolver, so a session with harness "opencode" spawns and restores a real opencode worker. opencode diverges from the claude-code/codex adapters in two ways the adapter bridges: - No native command-hook config. Unlike Claude Code (.claude/settings.local.json) and Codex (.codex/hooks.json), opencode has no "run this command on event" config (see sst/opencode#5409). Its only lifecycle surface is a JS/TS plugin loaded from .opencode/plugins/. GetAgentHooks therefore //go:embeds an AO-owned plugin (assets/ao-activity.ts) and writes it atomically; install is an idempotent overwrite and uninstall is a sentinel-guarded delete, so user-authored plugins are never touched. The plugin maps opencode events onto AO's three normalized activity events: session.created -> session-start, message.updated/message.part.updated -> user-prompt-submit, and session.status(idle) -> stop (NOT the deprecated session.idle, which is unreliable under `opencode run`). It shells `ao hooks opencode <event>` via a guarded sh -c so a missing `ao` binary is a silent no-op. - A single approval flag. opencode exposes only --dangerously-skip-permissions (no graduated accept-edits/auto) and no system-prompt flag, so those map to a bypass-only permission flag and a documented no-op for the system prompt (deferred to opencode's own config). Launch uses the interactive TUI (`opencode --prompt <p>`); restore continues a captured native session via `opencode --session <id>`. opencode_test.go mirrors codex_test.go (12 tests) and the daemon wiring test now asserts the opencode harness resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): address Greptile review on opencode adapter (#80) - Dispatch all opencode plugin hooks synchronously (Bun.spawnSync). The session.created handler previously fired session-start via an async Bun.spawn; if opencode does not await the event handler, a following message.updated -> user-prompt-submit (sync) could complete before the in-flight async session-start, so AO would see the prompt before the session was registered. A sync spawn blocks opencode's single-threaded event loop, so events are now reported strictly in dispatch order. Removes the now-unused async callHook helper. - Fix package/doc comments that said .opencode/plugin/ (singular) to match the plural .opencode/plugins/ the adapter actually writes to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): surface opencode hook failures instead of swallowing them The activity plugin previously discarded every failure: callHookSync ignored the subprocess exit code/stderr and both catch blocks were empty, so a failing `ao hooks` invocation or a malformed event payload was completely invisible. Now failures are reported through opencode's structured logger (client.app.log) while still never crashing opencode: - callHookSync pipes stderr and checks result.success; a non-zero exit (a real `ao hooks` failure — the `command -v ao` guard makes a missing binary exit 0) is logged with its exit code and stderr. - spawn exceptions (e.g. no `sh`) are caught and logged. - the event-handler catch logs the offending event type instead of swallowing. - logHookFailure is itself best-effort (optional-chained, rejection swallowed), so logging can never throw back into opencode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent): address opencode adapter review — install guard, prompt dedup, hook timeout Maintainer review on #80 surfaced three pre-merge issues: - GetAgentHooks could clobber a user file: install overwrote .opencode/plugins/ao-activity.ts unconditionally while uninstall was sentinel-guarded. Install now refuses (loud error) to overwrite a file that isn't AO-managed; absent/AO-managed targets still write idempotently. - Empty-prompt report poisoned the dedup: message.updated fired user-prompt-submit with an empty prompt AND marked the message seen, so the text from the following message.part.updated was deduped away and never reached AO — breaking title-from-prompt. reportUserPrompt now reports at most twice: an optional early empty report (keeps run-mode flows active) that does NOT block a later text report, and a text report that is terminal. - Bun.spawnSync had no timeout, so a hung `ao hooks` could block opencode indefinitely. Each spawn is now time-boxed at 30s, matching the claude-code and codex hook timeouts. Adds TestGetAgentHooksRefusesToClobberForeignFile and a spawn-timeout assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: harden opencode activity hooks --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
This commit is contained in:
parent
210c9df758
commit
e25b2ad4de
|
|
@ -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<string, boolean>()
|
||||
// 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<string, any>()
|
||||
|
||||
// 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<string, unknown>) {
|
||||
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))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <event>`; 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 <event>` 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)
|
||||
}
|
||||
|
|
@ -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 <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 <agentSessionId>`.
|
||||
// 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()
|
||||
}
|
||||
|
|
@ -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 <event>`.
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue