From 4e2144d99ed07487aaa05d8248cc0d89e304418c Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:55:44 +0530 Subject: [PATCH] feat: OpenCode session lifecycle and CLI controls (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: refine OpenCode session reuse strategy and cleanup * fix: harden OpenCode session selection and lint errors * refactor: centralize OpenCode reuse resolution flow * fix: return 404 for missing session in message route * feat: replace force remap with terminal reload control * fix: protect project path from session kill cleanup * fix: preserve fullscreen alignment without reload action * fix: harden OpenCode session id handling and title reuse selection * fix: show OpenCode reload control and remap before restart * fix: preserve title-only OpenCode reuse with fallback mapping persistence * fix: resolve remaining PR315 Bugbot findings * fix: keep OpenCode discovery title-based without timestamp sorting * fix: avoid enrichment race fallout in session listing * fix: guard OpenCode discovery parse with array check * fix: stabilize Linear comment integration check * fix: harden OpenCode discovery and prompt option flow * ux: clarify OpenCode terminal restart action * fix: remap OpenCode session fresh on each restart * fix: validate remap session ids before reuse * fix: clean archived metadata only after purge * docs: align OpenCode remap selection with title-based behavior * fix: harden opencode cleanup and ignore local sisyphus state * test: add timeout cleanup coverage for session enrichment * fix: harden Linear integration helper against transient non-JSON errors * fix: make linear integration assertions resilient to eventual consistency * fix: remove unused fs import after rebase * fix: address remaining Bugbot blockers for opencode session handling * fix: avoid stale metadata overwrite during restore post-launch * fix: forward subagent in orchestrator flows and defer reuse lookup * fix: apply configured subagent fallback for session spawn * fix: scope archived cleanup by project and delay archive restore write * fix: normalize orchestrator strategy aliases in start display logic * fix: centralize orchestrator strategy normalization in core * fix: derive orchestrator reuse display from spawn result * fix: keep cleanup results consistent across project-id collisions * fix: namespace cleanup results when session IDs collide * fix: harden GitHub issue stateReason fallback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: avoid false failing CI state mapping Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: add tmux command timeouts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: bound session API enrichment latency Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: repair scm-github merge resolution Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: repair lifecycle-manager test merge Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: delay archive metadata recreation until restore passes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * test: restore claim-pr session mocks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus * fix: address review findings for OpenCode lifecycle PR - Fix stripControlChars to preserve newlines for reload commands - Add SessionNotFoundError and use instanceof checks in API routes - Document orchestratorSessionStrategy in YAML example - Validate existingSessionId with asValidOpenCodeSessionId() - Extract inline Node script to buildSessionLookupScript helper - Create OpenCodeSessionManager interface for remap capability - Create OpenCodeAgentConfig type for agent-specific config - Change default orchestratorSessionStrategy from delete to reuse Co-Authored-By: Claude Opus 4.5 * fix: guard reused session display without metadata * chore: add agent config files to .gitignore Agent configuration files (CLAUDE.md, AGENTS.md, IMPROVEMENTS.md, etc.) are personal and project-specific. They should not be committed to the repository. Changes: - Remove CLAUDE.md from git tracking - Add agent config files to .gitignore - Create .gitignore-template for reference Co-Authored-By: Claude Opus 4.5 * chore: update gitignore for agent config folder structure Reorganized agent configuration files: - CLAUDE.md and AGENTS.md stay in root (agents read them there) - Tracking files move to .opencode/ (IMPROVEMENTS.md, etc.) - Optional Claude files in .claude/ Updated .gitignore to ignore folders instead of individual files. Co-Authored-By: Claude Opus 4.5 * fix: tighten opencode remap and session discovery safeguards * fix: address Bugbot findings in session manager * fix: scope opencode discovery to opencode sessions * fix: restore concurrent listing and strict permission literals * fix: throw SessionNotFoundError, parallelize list enrichment, fix permissions type - Session manager now throws SessionNotFoundError instead of plain Error for missing sessions, so web API routes correctly return 404 (not 500) - Parallelize session enrichment in list() — was sequential, causing O(N) latency for N sessions with subprocess enrichment - Fix AgentLaunchConfig.permissions type to accept legacy "skip" value (AgentPermissionInput instead of AgentPermissionMode) - Add happy-path and validation tests for /api/sessions/:id/message route - Update all test mocks to use SessionNotFoundError Co-Authored-By: Claude Opus 4.6 * fix: route ao send through session manager * feat: add purge option to orchestrator stop * fix: register opencode agent in web services * test: align web API missing-session coverage * fix: match notifier config by plugin name * refactor: dedupe session lookup and tmux buffer send flow * test: update send lifecycle wait expectation * fix: harden send routing and cleanup purge controls --------- Co-authored-by: Harsh Co-authored-by: Sisyphus Co-authored-by: Claude Opus 4.5 Co-authored-by: Prateek --- .gitignore | 7 + .gitignore-template | 5 + CLAUDE.md | 222 --- agent-orchestrator.yaml.example | 7 + docs/opencode-workflows-spec.md | 104 + packages/cli/__tests__/commands/send.test.ts | 188 +- .../cli/__tests__/commands/session.test.ts | 156 +- packages/cli/__tests__/commands/start.test.ts | 102 +- packages/cli/__tests__/lib/plugins.test.ts | 4 + packages/cli/package.json | 2 + packages/cli/src/commands/init.ts | 11 +- packages/cli/src/commands/send.ts | 180 +- packages/cli/src/commands/session.ts | 59 +- packages/cli/src/commands/start.ts | 76 +- .../cli/src/lib/create-session-manager.ts | 6 +- packages/cli/src/lib/plugins.ts | 2 + packages/core/package.json | 1 + .../src/__tests__/config-validation.test.ts | 20 + .../src/__tests__/lifecycle-manager.test.ts | 7 +- .../src/__tests__/opencode-session-id.test.ts | 16 + .../orchestrator-session-strategy.test.ts | 20 + .../src/__tests__/plugin-integration.test.ts | 24 + .../src/__tests__/plugin-registry.test.ts | 56 + .../src/__tests__/session-manager.test.ts | 1735 ++++++++++++++++- packages/core/src/config.ts | 6 + packages/core/src/index.ts | 3 + packages/core/src/metadata.ts | 45 + packages/core/src/opencode-session-id.ts | 8 + .../core/src/orchestrator-session-strategy.ts | 11 + packages/core/src/plugin-registry.ts | 20 +- packages/core/src/session-manager.ts | 752 +++++-- packages/core/src/types.ts | 59 +- packages/integration-tests/package.json | 1 + .../src/agent-claude-code.integration.test.ts | 13 +- .../src/agent-opencode.integration.test.ts | 307 ++- .../src/tracker-linear.integration.test.ts | 161 +- .../plugins/agent-opencode/src/index.test.ts | 277 ++- packages/plugins/agent-opencode/src/index.ts | 133 +- .../runtime-tmux/src/__tests__/index.test.ts | 177 +- packages/plugins/runtime-tmux/src/index.ts | 5 +- packages/plugins/scm-github/src/index.ts | 131 +- .../plugins/scm-github/test/index.test.ts | 62 +- packages/plugins/tracker-github/src/index.ts | 82 +- .../plugins/tracker-github/test/index.test.ts | 62 + packages/web/package.json | 1 + packages/web/src/__tests__/api-routes.test.ts | 130 +- packages/web/src/__tests__/services.test.ts | 99 + .../src/app/api/sessions/[id]/kill/route.ts | 7 +- .../app/api/sessions/[id]/message/route.ts | 32 +- .../src/app/api/sessions/[id]/remap/route.ts | 27 + .../app/api/sessions/[id]/restore/route.ts | 12 +- .../src/app/api/sessions/[id]/send/route.ts | 7 +- packages/web/src/app/api/sessions/route.ts | 55 +- .../web/src/components/DirectTerminal.tsx | 169 +- packages/web/src/components/SessionDetail.tsx | 166 +- packages/web/src/lib/services.ts | 7 +- packages/web/src/lib/validation.ts | 5 +- pnpm-lock.yaml | 289 +++ 58 files changed, 5470 insertions(+), 861 deletions(-) create mode 100644 .gitignore-template delete mode 100644 CLAUDE.md create mode 100644 docs/opencode-workflows-spec.md create mode 100644 packages/core/src/__tests__/opencode-session-id.test.ts create mode 100644 packages/core/src/__tests__/orchestrator-session-strategy.test.ts create mode 100644 packages/core/src/opencode-session-id.ts create mode 100644 packages/core/src/orchestrator-session-strategy.ts create mode 100644 packages/web/src/__tests__/services.test.ts create mode 100644 packages/web/src/app/api/sessions/[id]/remap/route.ts diff --git a/.gitignore b/.gitignore index 9a9ed1648..f92763788 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,14 @@ id_ed25519 # Development symlinks (created per-worktree, not committed) .claude +.sisyphus packages/web/agent-orchestrator.yaml # Local agent orchestrator config (may contain secrets) agent-orchestrator.yaml + +# Agent configuration and tracking (personal, not for repo) +CLAUDE.md +AGENTS.md +.claude/ +.opencode/ diff --git a/.gitignore-template b/.gitignore-template new file mode 100644 index 000000000..3cc2a555a --- /dev/null +++ b/.gitignore-template @@ -0,0 +1,5 @@ +# Agent configuration and tracking (personal, not for repo) +# Add these to .gitignore for ALL projects with agent configs + +.claude/ +.opencode/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7761cab28..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,222 +0,0 @@ -# CLAUDE.md — Agent Orchestrator - -## What This Is - -Open-source system for orchestrating parallel AI coding agents. Agent-agnostic (Claude Code, Codex, Aider), runtime-agnostic (tmux, docker, k8s), tracker-agnostic (GitHub, Linear, Jira). Manages session lifecycle, tracks PR/CI/review state, auto-handles routine issues (CI failures, review comments), pushes notifications to humans only when needed. - -**Core principle: Push, not pull.** Spawn agents, walk away, get notified when your judgment is needed. - -## Tech Stack - -TypeScript (ESM), Node 20+, pnpm workspaces. Next.js 15 (App Router) + Tailwind. Commander.js CLI. YAML + Zod config. Server-Sent Events for real-time. Flat metadata files + JSONL event log. ESLint + Prettier. vitest. - -## Architecture - -8 plugin slots — every abstraction is swappable: - -| Slot | Interface | Default Plugin | -| --------- | ----------- | -------------- | -| Runtime | `Runtime` | tmux | -| Agent | `Agent` | claude-code | -| Workspace | `Workspace` | worktree | -| Tracker | `Tracker` | github | -| SCM | `SCM` | github | -| Notifier | `Notifier` | desktop | -| Terminal | `Terminal` | iterm2 | -| Lifecycle | (core) | — | - -**All interfaces defined in `packages/core/src/types.ts` — read this file first.** - -## Directory Structure - -``` -packages/ - core/ — @composio/ao-core (types, config, services) - cli/ — @composio/ao-cli (the `ao` command) - web/ — @composio/ao-web (Next.js dashboard) - plugins/ - runtime-{tmux,process}/ - agent-{claude-code,codex,aider,opencode}/ - workspace-{worktree,clone}/ - tracker-{github,linear}/ - scm-github/ - notifier-{desktop,slack,composio,webhook}/ - terminal-{iterm2,web}/ -``` - -## Key Files (Read These First) - -1. `packages/core/src/types.ts` — all interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal) -2. `agent-orchestrator.yaml.example` — config format -3. Plugin examples: - - `packages/plugins/runtime-tmux/src/index.ts` — Runtime implementation - - `packages/plugins/agent-claude-code/src/index.ts` — Agent implementation -4. This file (CLAUDE.md) — code conventions - -## TypeScript Conventions (MUST follow) - -- **ESM modules** — `"type": "module"` in all packages -- **`.js` extensions in imports** — `import { foo } from "./bar.js"` (required for ESM) -- **`node:` prefix for builtins** — `import { readFileSync } from "node:fs"` -- **Strict mode** — `"strict": true` in tsconfig -- **`type` imports** — `import type { Foo }` for type-only (enforced by ESLint) -- **No `any`** — use `unknown` + type guards (ESLint error) -- **No unsafe casts** — `as unknown as T` bypasses type safety, validate instead -- **Prefer `const`** — `let` only when reassignment needed, never `var` -- **Semicolons, double quotes, 2-space indent** — enforced by Prettier - -## Plugin Pattern (MUST follow) - -Every plugin exports a `PluginModule` with inline `satisfies` for compile-time type checking: - -```typescript -import type { PluginModule, Runtime } from "@composio/ao-core"; - -export const manifest = { - name: "tmux", - slot: "runtime" as const, - description: "Runtime plugin: tmux sessions", - version: "0.1.0", -}; - -export function create(): Runtime { - return { - name: "tmux", - async create(config) { - /* ... */ - }, - async destroy(handle) { - /* ... */ - }, - // ... implement interface methods - }; -} - -export default { manifest, create } satisfies PluginModule; -``` - -**Do NOT** use `const plugin = { ... }; export default plugin;` — always inline `satisfies`. - -## Shell Command Execution (MUST follow — security critical) - -- **Always use `execFile`** (or `spawn`) — NEVER `exec` (shell injection risk) -- **Always add timeouts** — `{ timeout: 30_000 }` for external commands -- **Never interpolate user input** — pass as array args, not string template -- **Do NOT use `JSON.stringify` for shell escaping** — not a shell escaping function - -```typescript -// GOOD -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -const execFileAsync = promisify(execFile); -const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { timeout: 30_000 }); - -// BAD — shell injection risk -exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf / -``` - -## Error Handling - -- Throw typed errors, don't return error codes -- Plugins throw if they can't do their job -- Core services catch and handle plugin errors -- **Always wrap `JSON.parse`** in try/catch (corrupted metadata should not crash) -- **Guard external data** — validate types from API/CLI/file inputs - -## Naming - -- Files: `kebab-case.ts` -- Types/Interfaces: `PascalCase` -- Functions/variables: `camelCase` -- Constants: `UPPER_SNAKE_CASE` (only true constants: env vars, regex patterns) -- Test files: `*.test.ts` (co-located or in `__tests__/`) - -## Commands - -```bash -pnpm install # install deps -pnpm build # build all packages -pnpm typecheck # typecheck -pnpm lint # ESLint check -pnpm lint:fix # ESLint auto-fix -pnpm format # Prettier format -pnpm format:check # Prettier check (CI) -pnpm test # run tests - -# Before committing -pnpm lint && pnpm typecheck -``` - -## Development Workflow - -### Running the Dev Server - -**IMPORTANT**: The web dashboard depends on built packages. Always build before running dev server. - -```bash -# 1. Install dependencies (first time only) -pnpm install - -# 2. Build all packages (required before dev server) -pnpm build - -# 3. Ensure config exists -# Copy agent-orchestrator.yaml.example to agent-orchestrator.yaml and configure -cp agent-orchestrator.yaml.example agent-orchestrator.yaml - -# 4. Run the dev server -cd packages/web -pnpm dev -``` - -**Why build first?** The web package imports from `@composio/ao-core` and plugin packages. These must be built (TypeScript compiled to JavaScript) before Next.js can resolve them. - -**Config requirement**: The app expects `agent-orchestrator.yaml` in the working directory. Without it, all API routes will fail with "No agent-orchestrator.yaml found". - -### Working with Worktrees - -If using git worktrees (common for parallel agent work): - -```bash -# After creating a worktree -cd /path/to/worktree -pnpm install # Install deps -pnpm build # Build packages -cp /path/to/main/agent-orchestrator.yaml . # Copy config -cd packages/web && pnpm dev # Start server -``` - -## Using Playwright (MCP browser tool) - -Before navigating with Playwright, kill any stale Chrome for Testing instance first — otherwise it fails silently with "Opening in existing browser session": - -```bash -pkill -f "Google Chrome for Testing" -``` - -Then use `browser_navigate` as normal. If Playwright was previously used in the session it may have left an orphaned Chrome process. - -## Common Mistakes to Avoid - -- Using `exec` instead of `execFile` — security vulnerability -- Using `JSON.stringify` for shell escaping — does not escape `$`, backticks, `$()` -- Missing `.js` extension in local imports — runtime error with ESM -- Using bare `"fs"` instead of `"node:fs"` — inconsistent -- Casting with `as unknown as T` — bypasses type safety, crashes on bad data -- `export default plugin` without `satisfies PluginModule` — loses type checking -- Interpolating user input into shell commands, AppleScript, or GraphQL queries -- Forgetting to clean up setInterval/setTimeout on disconnect/destroy -- Using `on("exit")` instead of `once("exit")` for one-time handlers - -## Config - -Config loaded from `agent-orchestrator.yaml` (see `agent-orchestrator.yaml.example`). Paths support `~` expansion. Validated with Zod at load time. Per-project overrides for plugins and reactions. - -## Design Decisions - -1. **Stateless orchestrator** — no database, flat metadata files + event log -2. **Plugins implement interfaces** — pure implementation of interface from `types.ts` -3. **Push notifications** — Notifier is primary human interface, not dashboard -4. **Two-tier event handling** — auto-handle routine issues, notify human when judgment needed -5. **Backwards-compatible metadata** — flat key=value files -6. **Security first** — `execFile` not `exec`, validate all external input diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 285a2d853..b0448b53c 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -64,6 +64,13 @@ projects: # orchestratorRules: | # Prefer to batch-spawn related issues together. + # OpenCode issue session strategy (only for agent: opencode) + # opencodeIssueSessionStrategy: reuse # reuse | delete | ignore + + # OpenCode orchestrator session strategy (only for agent: opencode) + # Controls how the orchestrator's OpenCode session is managed + # orchestratorSessionStrategy: reuse # reuse | delete | ignore + # Per-project reaction overrides # reactions: # approved-and-green: diff --git a/docs/opencode-workflows-spec.md b/docs/opencode-workflows-spec.md new file mode 100644 index 000000000..4a9fa7ec5 --- /dev/null +++ b/docs/opencode-workflows-spec.md @@ -0,0 +1,104 @@ +# OpenCode Workflow Spec (Agent Orchestrator) + +This document defines intended behavior for Agent Orchestrator when `agent: opencode` is selected, including edge cases and expected outcomes. + +## Scope + +- CLI workflows: `ao start`, `ao spawn`, `ao status`, `ao send`, `ao session cleanup`, `ao session restore`, `ao session remap`. +- Core lifecycle paths in `SessionManager` and plugin resolution. +- OpenCode session mapping and deletion semantics. + +## Configuration Contract + +- `defaults.agent: opencode` or `projects..agent: opencode` selects the OpenCode agent plugin. +- `projects..orchestratorSessionStrategy` controls orchestrator session behavior: + - `reuse`: reuse existing alive orchestrator runtime; otherwise restart and reuse mapped OpenCode session id when available. + - `delete`: destroy alive runtime, delete previously mapped/discovered OpenCode orchestrator sessions, then start fresh. + - `ignore`: destroy alive runtime and start fresh without deleting prior OpenCode sessions. + - `delete-new` and `kill-previous` normalize to `delete`. + - `ignore-new` normalizes to `ignore`. +- `projects..opencodeIssueSessionStrategy` controls issue-session reuse for `ao spawn` with OpenCode: + - `reuse` (default): reuse mapped OpenCode session for same issue when available. + - `delete`: delete mapped OpenCode sessions for same issue, then spawn fresh. + - `ignore`: spawn fresh without deleting prior issue sessions. + +## Workflow Behavior + +## 1) Plugin Resolution + +- CLI must resolve `opencode` via `getAgentByName` and `getAgent` without error. +- Core plugin registry built-ins must include `@composio/ao-plugin-agent-opencode` under slot `agent`. +- Expected failure mode: unknown agent names fail fast with `Unknown agent plugin: `. + +## 2) `ao start` (orchestrator session) + +- Always delegates orchestrator session lifecycle to `SessionManager.spawnOrchestrator`. +- For `orchestratorSessionStrategy: reuse`: + - if existing runtime is alive, return existing session without creating a new runtime. + - if existing runtime is dead and metadata contains `opencodeSessionId`, pass it to launch config for continuation. +- For `delete` strategy: + - delete mapped/discovered OpenCode orchestrator sessions (`AO:-orchestrator`) before launching new orchestrator. +- For `ignore` strategy: + - do not delete old OpenCode sessions; launch fresh runtime. + +## 3) `ao spawn` + +- Uses selected agent (default/project/override) and launches OpenCode command from plugin launch config. +- OpenCode launch behavior: + - no mapped id: run with `--title AO:` and then continue via discovered session id. + - mapped id (`agentConfig.opencodeSessionId`): launch directly with `--session `. +- Model/subagent/system prompt inputs are forwarded into OpenCode launch command. + +## 4) `ao send` + +- Resolves agent by project config; when session metadata indicates OpenCode and mapping missing, core `send()` attempts title-based discovery and persists mapping. +- Sends message through runtime plugin handle; fails if session/runtime cannot be resolved. +- Busy detection is plugin-driven (`detectActivity`). + +## 5) `ao status` + +- Uses project/default configured agent for session enrichment and activity checks. +- Must not fail solely because project default agent is `opencode`. +- Fallback mode (no config) uses `claude-code` for best-effort tmux introspection only. + +## 6) `ao session cleanup` + +- Never cleans up orchestrator sessions (by explicit `role=orchestrator` or `-orchestrator` suffix). +- For OpenCode sessions with mapped `opencodeSessionId`: + - on cleanup kill path, delete corresponding OpenCode session first, then archive AO session metadata. + - archived sessions with mapping are cleaned once; `opencodeCleanedAt` prevents repeated deletion attempts. +- If OpenCode delete returns "session not found", treat as already cleaned. + +## 7) `ao session restore` + +- For OpenCode session restore, mapping is required. +- If mapping missing: + - attempt title discovery using longer interactive timeout. + - if still missing, fail with non-restorable error (`OpenCode session mapping is missing`). +- Restore must recreate runtime with preserved metadata/session fields and keep mapping persisted. + +## 8) `ao session remap` + +- Only valid for OpenCode sessions. +- `remap(session, force=false)`: + - reuse existing mapping if present; otherwise discover and persist. +- `remap(session, force=true)`: + - always re-discover by title and overwrite persisted mapping. +- If discovery fails, return explicit mapping-missing error. + +## Edge Cases and Expected Outcomes + +- OpenCode binary missing: OpenCode-specific operations relying on `opencode session ...` discovery/deletion degrade gracefully where coded (discovery returns none), and explicit operations report mapping/deletion errors when required. +- Corrupted `runtimeHandle` metadata: `send` fails with `Corrupted runtime handle`. +- Existing orchestrator metadata present but runtime dead under `reuse`: restart runtime and pass mapped `opencodeSessionId` when available. +- Duplicate OpenCode sessions with same AO title: title match drives selection for remap/discovery (no timestamp ranking). +- Archived OpenCode sessions already cleaned: `cleanup` skips duplicate deletion via `opencodeCleanedAt`. +- Unknown project/agent: fail fast with clear error. + +## Revalidation Baseline (Current) + +- Unit/integration validation that should remain green for OpenCode workflows: + - `@composio/ao-plugin-agent-opencode` tests. + - `@composio/ao-core` tests: `session-manager.test.ts`, `plugin-registry.test.ts`. + - `@composio/ao-cli` tests: `plugins.test.ts`, `start.test.ts`, `session.test.ts`, `send.test.ts`, `status.test.ts`. + - `@composio/ao-integration-tests` with `test:integration` (includes `agent-opencode.integration.test.ts`, conditionally skipped tests where prerequisites are unavailable). diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index 9d5e18eeb..990d9ceef 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; const { mockTmux, mockExec, mockDetectActivity } = vi.hoisted(() => ({ mockTmux: vi.fn(), @@ -6,6 +9,14 @@ const { mockTmux, mockExec, mockDetectActivity } = vi.hoisted(() => ({ mockDetectActivity: vi.fn(), })); +const { mockConfigRef, mockSessionManager } = vi.hoisted(() => ({ + mockConfigRef: { current: null as Record | null }, + mockSessionManager: { + get: vi.fn(), + send: vi.fn(), + }, +})); + vi.mock("../../src/lib/shell.js", () => ({ tmux: mockTmux, exec: mockExec, @@ -33,10 +44,17 @@ vi.mock("../../src/lib/session-utils.js", () => ({ vi.mock("@composio/ao-core", () => ({ loadConfig: () => { - throw new Error("no config"); + if (!mockConfigRef.current) { + throw new Error("no config"); + } + return mockConfigRef.current; }, })); +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: async () => mockSessionManager, +})); + import { Command } from "commander"; import { registerSend } from "../../src/commands/send.js"; @@ -58,6 +76,9 @@ beforeEach(() => { mockTmux.mockReset(); mockExec.mockReset(); mockDetectActivity.mockReset(); + mockSessionManager.get.mockReset(); + mockSessionManager.send.mockReset(); + mockConfigRef.current = null; mockExec.mockResolvedValue({ stdout: "", stderr: "" }); }); @@ -245,4 +266,169 @@ describe("send command", () => { expect(mockExec).toHaveBeenCalledWith("tmux", ["send-keys", "-t", "my-session", "C-u"]); }); }); + + describe("session manager integration", () => { + function makeConfig(): Record { + return { + configPath: "/tmp/agent-orchestrator.yaml", + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, + projects: { + "my-app": { + name: "My App", + sessionPrefix: "app", + path: "/tmp/my-app", + defaultBranch: "main", + repo: "org/my-app", + agent: "claude-code", + runtime: "tmux", + }, + }, + notifiers: {}, + notificationRouting: {}, + reactions: {}, + }; + } + + it("routes AO sessions through SessionManager.send", async () => { + mockConfigRef.current = makeConfig(); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: "idle", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockResolvedValue(undefined); + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "capture-pane") return "❯ "; + return ""; + }); + mockDetectActivity.mockReturnValue("idle"); + + await program.parseAsync(["node", "test", "send", "app-1", "hello", "opencode"]); + + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "hello opencode"); + expect(mockExec).not.toHaveBeenCalledWith( + "tmux", + expect.arrayContaining(["send-keys", "-l", "hello opencode"]), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Message sent and processing"), + ); + }); + + it("skips tmux busy detection when lifecycle send handles delivery", async () => { + mockConfigRef.current = makeConfig(); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: "active", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockResolvedValue(undefined); + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "capture-pane") return "some output"; + return ""; + }); + mockDetectActivity.mockReturnValueOnce("active").mockReturnValueOnce("idle"); + + await program.parseAsync(["node", "test", "send", "app-1", "fix", "mapping"]); + + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "fix mapping"); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Waiting for app-1 to become idle"), + ); + expect(mockTmux).not.toHaveBeenCalledWith( + "capture-pane", + "-t", + "tmux-target-1", + "-p", + "-S", + expect.any(String), + ); + }); + + it("skips tmux checks for non-tmux AO sessions and still uses lifecycle send", async () => { + mockConfigRef.current = makeConfig(); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: "active", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "proc-1", runtimeName: "process", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "send", "app-1", "hello"]); + + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "hello"); + expect(mockTmux).not.toHaveBeenCalledWith("has-session", "-t", expect.any(String)); + }); + + it("passes file contents through SessionManager.send for AO sessions", async () => { + mockConfigRef.current = makeConfig(); + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: "idle", + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: { agent: "opencode" }, + }); + mockSessionManager.send.mockResolvedValue(undefined); + mockTmux.mockImplementation(async (...args: string[]) => { + if (args[0] === "capture-pane") return "❯ "; + return ""; + }); + mockDetectActivity.mockReturnValue("idle"); + + const filePath = join(tmpdir(), `ao-send-message-${Date.now()}.txt`); + writeFileSync(filePath, "from file"); + + try { + await program.parseAsync(["node", "test", "send", "app-1", "--file", filePath]); + } finally { + rmSync(filePath, { force: true }); + } + + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "from file"); + }); + }); }); diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts index ccd6f5130..09f885026 100644 --- a/packages/cli/__tests__/commands/session.test.ts +++ b/packages/cli/__tests__/commands/session.test.ts @@ -8,35 +8,65 @@ import { readdirSync, rmSync, } from "node:fs"; +import { EventEmitter } from "node:events"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import type * as ChildProcessModule from "node:child_process"; import { type Session, type CleanupResult, type SessionManager, + SessionNotFoundError, getSessionsDir, getProjectBaseDir, } from "@composio/ao-core"; -const { mockTmux, mockGit, mockGh, mockExec, mockConfigRef, mockSessionManager, sessionsDirRef } = - vi.hoisted(() => ({ - mockTmux: vi.fn(), - mockGit: vi.fn(), - mockGh: vi.fn(), - mockExec: vi.fn(), - mockConfigRef: { current: null as Record | null }, - mockSessionManager: { - list: vi.fn(), - kill: vi.fn(), - cleanup: vi.fn(), - get: vi.fn(), - spawn: vi.fn(), - spawnOrchestrator: vi.fn(), - send: vi.fn(), - claimPR: vi.fn(), - }, - sessionsDirRef: { current: "" }, - })); +const { + mockTmux, + mockGit, + mockGh, + mockExec, + mockSpawn, + mockConfigRef, + mockSessionManager, + sessionsDirRef, +} = vi.hoisted(() => ({ + mockTmux: vi.fn(), + mockGit: vi.fn(), + mockGh: vi.fn(), + mockExec: vi.fn(), + mockSpawn: vi.fn(), + mockConfigRef: { current: null as Record | null }, + mockSessionManager: { + list: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + restore: vi.fn(), + remap: vi.fn(), + get: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + }, + sessionsDirRef: { current: "" }, +})); + +function makeMockChild(exitCode: number): EventEmitter { + const child = new EventEmitter(); + queueMicrotask(() => { + child.emit("exit", exitCode); + }); + return child; +} + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }; +}); vi.mock("../../src/lib/shell.js", () => ({ tmux: mockTmux, @@ -166,14 +196,19 @@ beforeEach(() => { mockGit.mockReset(); mockGh.mockReset(); mockExec.mockReset(); + mockSpawn.mockReset(); mockSessionManager.list.mockReset(); mockSessionManager.kill.mockReset(); mockSessionManager.cleanup.mockReset(); + mockSessionManager.restore.mockReset(); + mockSessionManager.remap.mockReset(); mockSessionManager.get.mockReset(); mockSessionManager.spawn.mockReset(); mockSessionManager.send.mockReset(); mockSessionManager.claimPR.mockReset(); + mockSpawn.mockImplementation(() => makeMockChild(0)); + // Default: list reads from sessionsDir mockSessionManager.list.mockImplementation(async () => { return buildSessionsFromDir(sessionsDirRef.current, "my-app"); @@ -188,6 +223,8 @@ beforeEach(() => { skipped: [], errors: [], } satisfies CleanupResult); + mockSessionManager.restore.mockResolvedValue(undefined); + mockSessionManager.remap.mockResolvedValue("ses_mock"); mockSessionManager.claimPR.mockResolvedValue({ sessionId: "app-1", projectId: "my-app", @@ -298,7 +335,7 @@ describe("session ls", () => { describe("session kill", () => { it("rejects unknown session (no matching project)", async () => { - mockSessionManager.kill.mockRejectedValue(new Error("Session not found: unknown-1")); + mockSessionManager.kill.mockRejectedValue(new SessionNotFoundError("unknown-1")); await expect( program.parseAsync(["node", "test", "session", "kill", "unknown-1"]), @@ -317,7 +354,7 @@ describe("session kill", () => { const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); expect(output).toContain("Session app-1 killed."); - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1"); + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false }); }); it("calls session manager kill with the session name", async () => { @@ -327,7 +364,53 @@ describe("session kill", () => { await program.parseAsync(["node", "test", "session", "kill", "app-1"]); - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1"); + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false }); + }); + + it("passes purge flag for OpenCode cleanup", async () => { + mockSessionManager.kill.mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "session", "kill", "app-1", "--purge-session"]); + + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true }); + }); +}); + +describe("session attach", () => { + it("attaches to resolved runtime target when session exists", async () => { + mockSessionManager.get.mockResolvedValue({ + id: "app-1", + projectId: "my-app", + status: "working", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + } satisfies Session); + + mockTmux.mockResolvedValue(""); + + await program.parseAsync(["node", "test", "session", "attach", "app-1"]); + + expect(mockTmux).toHaveBeenCalledWith("has-session", "-t", "tmux-target-1"); + expect(mockSpawn).toHaveBeenCalledWith("tmux", ["attach", "-t", "tmux-target-1"], { + stdio: "inherit", + }); + }); + + it("fails when tmux session does not exist", async () => { + mockSessionManager.get.mockResolvedValue(null); + mockTmux.mockResolvedValue(null); + + await expect( + program.parseAsync(["node", "test", "session", "attach", "unknown-1"]), + ).rejects.toThrow("process.exit(1)"); }); }); @@ -483,3 +566,32 @@ describe("session cleanup", () => { expect(output).toContain("No sessions to clean up"); }); }); + +describe("session remap", () => { + it("remaps OpenCode session and reports mapped id", async () => { + mockSessionManager.remap.mockResolvedValue("ses_123"); + + await program.parseAsync(["node", "test", "session", "remap", "app-1"]); + + expect(mockSessionManager.remap).toHaveBeenCalledWith("app-1", false); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("Session app-1 remapped."); + expect(output).toContain("OpenCode session: ses_123"); + }); + + it("passes force flag to remap", async () => { + mockSessionManager.remap.mockResolvedValue("ses_123"); + + await program.parseAsync(["node", "test", "session", "remap", "app-1", "--force"]); + + expect(mockSessionManager.remap).toHaveBeenCalledWith("app-1", true); + }); + + it("fails with exit code when remap errors", async () => { + mockSessionManager.remap.mockRejectedValue(new Error("mapping failed")); + + await expect(program.parseAsync(["node", "test", "session", "remap", "app-1"])).rejects.toThrow( + "process.exit(1)", + ); + }); +}); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 3b2863c37..44eb43a76 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -67,8 +67,17 @@ vi.mock("ora", () => ({ vi.mock("@composio/ao-core", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importOriginal(); + const normalizeOrchestratorSessionStrategy = + actual.normalizeOrchestratorSessionStrategy ?? + ((strategy: string | undefined) => { + if (strategy === "kill-previous" || strategy === "delete-new") return "delete"; + if (strategy === "ignore-new") return "ignore"; + return strategy ?? "reuse"; + }); + return { ...actual, + normalizeOrchestratorSessionStrategy, loadConfig: (path?: string) => { if (path) return actual.loadConfig(path); return mockConfigRef.current; @@ -610,6 +619,83 @@ describe("start command — browser open waits for port", () => { }); }); +describe("start command — orchestrator session strategy display", () => { + function getLoggedOutput(): string { + return vi + .mocked(console.log) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + } + + it("shows reused messaging when strategy is reuse and metadata marks the session reused", async () => { + mockConfigRef.current = makeConfig({ + "my-app": makeProject({ orchestratorSessionStrategy: "reuse" }), + }); + + mockSessionManager.get.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + }); + mockSessionManager.spawnOrchestrator.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + metadata: { orchestratorSessionReused: "true" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + expect(output).toContain("reused existing session (app-orchestrator)"); + expect(output).not.toContain("tmux attach -t tmux-session-1"); + }); + + it("falls back to attach messaging when strategy is reuse but metadata is missing", async () => { + mockConfigRef.current = makeConfig({ + "my-app": makeProject({ orchestratorSessionStrategy: "reuse" }), + }); + + mockSessionManager.get.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + }); + mockSessionManager.spawnOrchestrator.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + expect(output).toContain("tmux attach -t tmux-session-1"); + expect(output).not.toContain("reused existing session"); + }); + + it.each(["delete", "ignore", "delete-new", "ignore-new", "kill-previous"] as const)( + "uses attach messaging when strategy is %s", + async (orchestratorSessionStrategy) => { + mockConfigRef.current = makeConfig({ + "my-app": makeProject({ orchestratorSessionStrategy }), + }); + + mockSessionManager.get.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + }); + mockSessionManager.spawnOrchestrator.mockResolvedValue({ + id: "app-orchestrator", + runtimeHandle: { id: "tmux-session-1" }, + metadata: { orchestratorSessionReused: "true" }, + }); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + expect(output).toContain("tmux attach -t tmux-session-1"); + expect(output).not.toContain("reused existing session"); + }, + ); +}); + // --------------------------------------------------------------------------- // ao stop // --------------------------------------------------------------------------- @@ -623,7 +709,9 @@ describe("stop command", () => { await program.parseAsync(["node", "test", "stop"]); - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator"); + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", { + purgeOpenCode: false, + }); expect(mockStopLifecycleWorker).toHaveBeenCalledWith( expect.objectContaining({ configPath: expect.any(String) }), "my-app", @@ -653,4 +741,16 @@ describe("stop command", () => { .join("\n"); expect(output).toContain("is not running"); }); + + it("passes purge flag when stopping orchestrator with --purge-session", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" }); + mockSessionManager.kill.mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "stop", "--purge-session"]); + + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", { + purgeOpenCode: true, + }); + }); }); diff --git a/packages/cli/__tests__/lib/plugins.test.ts b/packages/cli/__tests__/lib/plugins.test.ts index 04defd512..d558aa2d5 100644 --- a/packages/cli/__tests__/lib/plugins.test.ts +++ b/packages/cli/__tests__/lib/plugins.test.ts @@ -67,6 +67,10 @@ describe("getAgentByName", () => { expect(getAgentByName("aider").name).toBe("aider"); }); + it("returns agent for opencode", () => { + expect(getAgentByName("opencode").name).toBe("opencode"); + }); + it("throws on unknown name", () => { expect(() => getAgentByName("unknown")).toThrow("Unknown agent plugin: unknown"); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index b18e0b830..413c59a9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "@composio/ao-plugin-agent-aider": "workspace:*", "@composio/ao-plugin-agent-claude-code": "workspace:*", "@composio/ao-plugin-agent-codex": "workspace:*", + "@composio/ao-plugin-agent-opencode": "workspace:*", "@composio/ao-plugin-notifier-composio": "workspace:*", "@composio/ao-plugin-notifier-desktop": "workspace:*", "@composio/ao-plugin-notifier-slack": "workspace:*", @@ -56,6 +57,7 @@ "yaml": "^2.7.0" }, "devDependencies": { + "@vitest/coverage-v8": "^3.0.0", "@types/node": "^25.2.3", "tsx": "^4.19.0", "typescript": "^5.7.0", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b3e58ffab..9c98965c0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -147,10 +147,7 @@ export function registerInit(program: Command): void { .description("Interactive setup wizard — creates agent-orchestrator.yaml") .option("-o, --output ", "Output file path", "agent-orchestrator.yaml") .option("--auto", "Auto-generate config with sensible defaults (no prompts)") - .option( - "--smart", - "Analyze project and generate custom rules (coming soon — requires --auto)", - ) + .option("--smart", "Analyze project and generate custom rules (coming soon — requires --auto)") .action(async (opts: { output: string; auto?: boolean; smart?: boolean }) => { const outputPath = resolve(opts.output); @@ -260,7 +257,11 @@ export function registerInit(program: Command): void { // Default plugins console.log(chalk.bold("\n Default Plugins\n")); const runtime = await prompt(rl, "Runtime (tmux, process)", "tmux"); - const agent = await prompt(rl, "Agent (claude-code, codex, aider)", "claude-code"); + const agent = await prompt( + rl, + "Agent (claude-code, codex, aider, opencode)", + "claude-code", + ); const workspace = await prompt(rl, "Workspace (worktree, clone)", "worktree"); const notifiersStr = await prompt( rl, diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index e094862bd..83ea453bd 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; -import { type Agent, type Session, loadConfig } from "@composio/ao-core"; +import { + type Agent, + type OpenCodeSessionManager, + type Session, + loadConfig, +} from "@composio/ao-core"; import { exec, tmux } from "../lib/shell.js"; import { getAgentByName } from "../lib/plugins.js"; import { getSessionManager } from "../lib/create-session-manager.js"; @@ -12,9 +17,13 @@ import { getSessionManager } from "../lib/create-session-manager.js"; * Resolve session context: tmux target name and Agent plugin. * Loads config and looks up the session once, avoiding duplicate work. */ -async function resolveSessionContext( - sessionName: string, -): Promise<{ tmuxTarget: string; agent: Agent; session: Session | null }> { +async function resolveSessionContext(sessionName: string): Promise<{ + tmuxTarget: string; + runtimeName?: string; + agent: Agent; + session: Session | null; + sessionManager: OpenCodeSessionManager | null; +}> { try { const config = loadConfig(); const sm = await getSessionManager(config); @@ -22,13 +31,27 @@ async function resolveSessionContext( if (session) { const tmuxTarget = session.runtimeHandle?.id ?? sessionName; const project = config.projects[session.projectId]; - const agentName = project?.agent ?? config.defaults.agent; - return { tmuxTarget, agent: getAgentByName(agentName), session }; + const agentName = session.metadata["agent"] ?? project?.agent ?? config.defaults.agent; + const runtimeName = + session.runtimeHandle?.runtimeName ?? project?.runtime ?? config.defaults.runtime; + return { + tmuxTarget, + runtimeName, + agent: getAgentByName(agentName), + session, + sessionManager: sm, + }; } } catch { // No config or session not found — fall back to defaults } - return { tmuxTarget: sessionName, agent: getAgentByName("claude-code"), session: null }; + return { + tmuxTarget: sessionName, + runtimeName: "tmux", + agent: getAgentByName("claude-code"), + session: null, + sessionManager: null, + }; } function isActive(agent: Agent, terminalOutput: string): boolean { @@ -43,6 +66,50 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function readMessageInput(opts: { file?: string }, messageParts: string[]): Promise { + const inlineMessage = messageParts.join(" "); + if (!opts.file && !inlineMessage) { + console.error(chalk.red("No message provided")); + process.exit(1); + } + + if (!opts.file) { + return inlineMessage; + } + + try { + return readFileSync(opts.file, "utf-8"); + } catch (err) { + console.error(chalk.red(`Cannot read file: ${opts.file} (${err})`)); + process.exit(1); + } +} + +async function sendViaTmux(tmuxTarget: string, message: string): Promise { + await exec("tmux", ["send-keys", "-t", tmuxTarget, "C-u"]); + await sleep(200); + + if (message.includes("\n") || message.length > 200) { + const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`); + writeFileSync(tmpFile, message); + try { + await exec("tmux", ["load-buffer", tmpFile]); + await exec("tmux", ["paste-buffer", "-t", tmuxTarget]); + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore cleanup failure + } + } + } else { + await exec("tmux", ["send-keys", "-t", tmuxTarget, "-l", message]); + } + + await sleep(300); + await exec("tmux", ["send-keys", "-t", tmuxTarget, "Enter"]); +} + export function registerSend(program: Command): void { program .command("send") @@ -59,32 +126,38 @@ export function registerSend(program: Command): void { opts: { file?: string; wait?: boolean; timeout?: string }, ) => { // Resolve session context once: tmux target, agent plugin, session data - const { tmuxTarget, agent } = await resolveSessionContext(session); + const { + tmuxTarget, + runtimeName, + agent, + session: existingSession, + sessionManager, + } = await resolveSessionContext(session); - const exists = await tmux("has-session", "-t", tmuxTarget); - if (exists === null) { - console.error(chalk.red(`Session '${session}' does not exist`)); - process.exit(1); - } - - // Validate message input before any side effects - const msg = opts.file ? null : messageParts.join(" "); - if (!opts.file && !msg) { - console.error(chalk.red("No message provided")); - process.exit(1); - } + const message = await readMessageInput(opts, messageParts); const parsedTimeout = parseInt(opts.timeout || "600", 10); const timeoutMs = (isNaN(parsedTimeout) || parsedTimeout <= 0 ? 600 : parsedTimeout) * 1000; + const canUseTmux = runtimeName === "tmux"; + + if (!existingSession) { + const exists = await tmux("has-session", "-t", tmuxTarget); + if (exists === null) { + console.error(chalk.red(`Session '${session}' does not exist`)); + process.exit(1); + } + } + // Helper to capture output from the resolved tmux target async function captureOutput(lines: number): Promise { + if (!canUseTmux) return ""; const output = await tmux("capture-pane", "-t", tmuxTarget, "-p", "-S", String(-lines)); return output || ""; } - // Wait for idle - if (opts.wait !== false) { + const delegatesToSessionManager = Boolean(existingSession && sessionManager); + if (opts.wait !== false && canUseTmux && !delegatesToSessionManager) { const start = Date.now(); let warned = false; while (isActive(agent, await captureOutput(5))) { @@ -100,52 +173,27 @@ export function registerSend(program: Command): void { } } - // Clear partial input - await exec("tmux", ["send-keys", "-t", tmuxTarget, "C-u"]); - await sleep(200); - - // Send the message - if (opts.file) { - let content: string; - try { - content = readFileSync(opts.file, "utf-8"); - } catch (err) { - console.error(chalk.red(`Cannot read file: ${opts.file} (${err})`)); - process.exit(1); - } - const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`); - writeFileSync(tmpFile, content); - try { - await exec("tmux", ["load-buffer", tmpFile]); - await exec("tmux", ["paste-buffer", "-t", tmuxTarget]); - } finally { - try { - unlinkSync(tmpFile); - } catch { - // ignore cleanup failure - } - } - } else if (msg) { - if (msg.includes("\n") || msg.length > 200) { - const tmpFile = join(tmpdir(), `ao-send-${Date.now()}.txt`); - writeFileSync(tmpFile, msg); - try { - await exec("tmux", ["load-buffer", tmpFile]); - await exec("tmux", ["paste-buffer", "-t", tmuxTarget]); - } finally { - try { - unlinkSync(tmpFile); - } catch { - // ignore cleanup failure - } - } - } else { - await exec("tmux", ["send-keys", "-t", tmuxTarget, "-l", msg]); - } + if (existingSession && !sessionManager) { + console.error(chalk.red("AO-managed session found, but session manager is unavailable")); + process.exit(1); } - await sleep(300); - await exec("tmux", ["send-keys", "-t", tmuxTarget, "Enter"]); + if (!canUseTmux && !delegatesToSessionManager) { + console.error( + chalk.red( + `Session '${session}' is not tmux-backed and cannot be sent without lifecycle routing`, + ), + ); + process.exit(1); + } + + if (existingSession && sessionManager) { + await sessionManager.send(session, message); + console.log(chalk.green("Message sent and processing")); + return; + } + + await sendViaTmux(tmuxTarget, message); // Verify delivery with retries for (let attempt = 1; attempt <= 3; attempt++) { diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index ed54c88a3..9535dcc87 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -1,7 +1,8 @@ +import { spawn } from "node:child_process"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core"; -import { git, getTmuxActivity } from "../lib/shell.js"; +import { git, getTmuxActivity, tmux } from "../lib/shell.js"; import { formatAge } from "../lib/format.js"; import { getSessionManager } from "../lib/create-session-manager.js"; @@ -74,16 +75,49 @@ export function registerSession(program: Command): void { console.log(); }); + session + .command("attach") + .description("Attach to a session's tmux window") + .argument("", "Session name to attach") + .action(async (sessionName: string) => { + const config = loadConfig(); + const sm = await getSessionManager(config); + const sessionInfo = await sm.get(sessionName); + const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName; + + const exists = await tmux("has-session", "-t", tmuxTarget); + if (exists === null) { + console.error(chalk.red(`Session '${sessionName}' does not exist`)); + process.exit(1); + } + + await new Promise((resolve, reject) => { + const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" }); + child.once("error", (err) => reject(err)); + child.once("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + return; + } + reject(new Error(`tmux attach exited with code ${code}`)); + }); + }).catch((err) => { + console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`)); + process.exit(1); + }); + }); + session .command("kill") .description("Kill a session and remove its worktree") .argument("", "Session name to kill") - .action(async (sessionName: string) => { + .option("--purge-session", "Delete mapped OpenCode session during kill") + .action(async (sessionName: string, opts: { purgeSession?: boolean }) => { const config = loadConfig(); const sm = await getSessionManager(config); try { - await sm.kill(sessionName); + await sm.kill(sessionName, { purgeOpenCode: opts.purgeSession === true }); console.log(chalk.green(`\nSession ${sessionName} killed.`)); } catch (err) { console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`)); @@ -242,4 +276,23 @@ export function registerSession(program: Command): void { process.exit(1); } }); + + session + .command("remap") + .description("Re-discover and persist OpenCode session mapping for an AO session") + .argument("", "Session name to remap") + .option("-f, --force", "Force fresh remap by re-discovering the OpenCode session") + .action(async (sessionName: string, opts: { force?: boolean }) => { + const config = loadConfig(); + const sm = await getSessionManager(config); + + try { + const mapped = await sm.remap(sessionName, opts.force === true); + console.log(chalk.green(`\nSession ${sessionName} remapped.`)); + console.log(chalk.dim(` OpenCode session: ${mapped}`)); + } catch (err) { + console.error(chalk.red(`Failed to remap session ${sessionName}: ${err}`)); + process.exit(1); + } + }); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 1f3e5ae51..5147c855c 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -24,6 +24,7 @@ import { isRepoAlreadyCloned, generateConfigFromUrl, configToYaml, + normalizeOrchestratorSessionStrategy, type OrchestratorConfig, type ProjectConfig, type ParsedRepoUrl, @@ -31,7 +32,14 @@ import { import { exec, execSilent } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { ensureLifecycleWorker, stopLifecycleWorker } from "../lib/lifecycle-service.js"; -import { findWebDir, buildDashboardEnv, waitForPortAndOpen, isPortAvailable, findFreePort, MAX_PORT_SCAN } from "../lib/web-dir.js"; +import { + findWebDir, + buildDashboardEnv, + waitForPortAndOpen, + isPortAvailable, + findFreePort, + MAX_PORT_SCAN, +} from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; @@ -150,7 +158,9 @@ async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string): * Also returns the parsed URL so the caller can match by repo when the config * contains multiple projects. */ -async function handleUrlStart(url: string): Promise<{ config: OrchestratorConfig; parsed: ParsedRepoUrl; autoGenerated: boolean }> { +async function handleUrlStart( + url: string, +): Promise<{ config: OrchestratorConfig; parsed: ParsedRepoUrl; autoGenerated: boolean }> { const spinner = ora(); // 1. Parse URL @@ -253,12 +263,15 @@ async function runStartup( const shouldStartLifecycle = opts?.dashboard !== false || opts?.orchestrator !== false; let lifecycleStatus: Awaited> | null = null; let port = config.port ?? DEFAULT_PORT; + const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( + project.orchestratorSessionStrategy, + ); console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`)); const spinner = ora(); let dashboardProcess: ChildProcess | null = null; - let exists = false; + let reused = false; // Start dashboard (unless --no-dashboard) if (opts?.dashboard !== false) { @@ -324,37 +337,27 @@ async function runStartup( let tmuxTarget = sessionId; if (opts?.orchestrator !== false) { const sm = await getSessionManager(config); - const existing = await sm.get(sessionId); - exists = existing !== null && existing.status !== "killed"; - if (exists) { - if (existing?.runtimeHandle?.id) { - tmuxTarget = existing.runtimeHandle.id; + try { + spinner.start("Creating orchestrator session"); + const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); + const session = await sm.spawnOrchestrator({ projectId, systemPrompt }); + if (session.runtimeHandle?.id) { + tmuxTarget = session.runtimeHandle.id; } - console.log( - chalk.yellow( - `Orchestrator session "${sessionId}" is already running (skipping creation)`, - ), + reused = + orchestratorSessionStrategy === "reuse" && + session.metadata?.["orchestratorSessionReused"] === "true"; + spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created"); + } catch (err) { + spinner.fail("Orchestrator setup failed"); + if (dashboardProcess) { + dashboardProcess.kill(); + } + throw new Error( + `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, ); - } else { - try { - spinner.start("Creating orchestrator session"); - const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sm.spawnOrchestrator({ projectId, systemPrompt }); - if (session.runtimeHandle?.id) { - tmuxTarget = session.runtimeHandle.id; - } - spinner.succeed("Orchestrator session created"); - } catch (err) { - spinner.fail("Orchestrator setup failed"); - if (dashboardProcess) { - dashboardProcess.kill(); - } - throw new Error( - `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); - } } } @@ -373,10 +376,10 @@ async function runStartup( console.log(chalk.cyan("Lifecycle:"), lifecycleTarget); } - if (opts?.orchestrator !== false && !exists) { + if (opts?.orchestrator !== false && !reused) { console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`); - } else if (exists) { - console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`); + } else if (reused) { + console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`); } console.log(chalk.dim(`Config: ${config.configPath}\n`)); @@ -492,7 +495,8 @@ export function registerStop(program: Command): void { program .command("stop [project]") .description("Stop orchestrator agent and dashboard for a project") - .action(async (projectArg?: string) => { + .option("--purge-session", "Also purge the mapped OpenCode session when stopping") + .action(async (projectArg?: string, opts: { purgeSession?: boolean } = {}) => { try { const config = loadConfig(); const { projectId: _projectId, project } = resolveProject(config, projectArg); @@ -507,7 +511,7 @@ export function registerStop(program: Command): void { if (existing) { const spinner = ora("Stopping orchestrator session").start(); - await sm.kill(sessionId); + await sm.kill(sessionId, { purgeOpenCode: opts?.purgeSession === true }); spinner.succeed("Orchestrator session stopped"); } else { console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`)); diff --git a/packages/cli/src/lib/create-session-manager.ts b/packages/cli/src/lib/create-session-manager.ts index eaebd46fa..70419c144 100644 --- a/packages/cli/src/lib/create-session-manager.ts +++ b/packages/cli/src/lib/create-session-manager.ts @@ -12,7 +12,7 @@ import { createSessionManager, createLifecycleManager, type OrchestratorConfig, - type SessionManager, + type OpenCodeSessionManager, type PluginRegistry, type LifecycleManager, } from "@composio/ao-core"; @@ -42,7 +42,9 @@ async function getRegistry(config: OrchestratorConfig): Promise * Create a SessionManager backed by core's implementation. * Initializes the plugin registry from config and wires everything up. */ -export async function getSessionManager(config: OrchestratorConfig): Promise { +export async function getSessionManager( + config: OrchestratorConfig, +): Promise { const registry = await getRegistry(config); return createSessionManager({ config, registry }); } diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index b4b4435e6..7db71da54 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -2,12 +2,14 @@ import type { Agent, OrchestratorConfig, SCM } from "@composio/ao-core"; import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code"; import codexPlugin from "@composio/ao-plugin-agent-codex"; import aiderPlugin from "@composio/ao-plugin-agent-aider"; +import opencodePlugin from "@composio/ao-plugin-agent-opencode"; import githubSCMPlugin from "@composio/ao-plugin-scm-github"; const agentPlugins: Record = { "claude-code": claudeCodePlugin, codex: codexPlugin, aider: aiderPlugin, + opencode: opencodePlugin, }; const scmPlugins: Record = { diff --git a/packages/core/package.json b/packages/core/package.json index 18ba61736..a1c0756bb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,6 +43,7 @@ "zod": "^3.24.0" }, "devDependencies": { + "@vitest/coverage-v8": "^4.0.18", "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^4.0.18" diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 717ba0b57..a76ea7516 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -336,6 +336,26 @@ describe("Config Schema Validation", () => { expect(validated.projects.proj1.sessionPrefix).toBeDefined(); expect(validated.projects.proj1.sessionPrefix).toBe("test"); // "test" is 4 chars, used as-is }); + + it("accepts orchestratorModel in agentConfig", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + agentConfig: { + model: "worker-model", + orchestratorModel: "orchestrator-model", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.agentConfig?.model).toBe("worker-model"); + expect(validated.projects.proj1.agentConfig?.orchestratorModel).toBe("orchestrator-model"); + }); }); describe("Config Defaults", () => { diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 4f2289d88..42dc5ba7c 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -112,7 +112,7 @@ beforeEach(() => { cleanup: vi.fn(), send: vi.fn().mockResolvedValue(undefined), claimPR: vi.fn(), - }; + } as SessionManager; config = { configPath, @@ -970,10 +970,7 @@ describe("reactions", () => { await lm.check("app-1"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); - expect(mockSessionManager.send).toHaveBeenCalledWith( - "app-1", - "Handle requested changes.", - ); + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "Handle requested changes."); }); it("dispatches automated review comments only once for an unchanged backlog", async () => { diff --git a/packages/core/src/__tests__/opencode-session-id.test.ts b/packages/core/src/__tests__/opencode-session-id.test.ts new file mode 100644 index 000000000..6ae33ab23 --- /dev/null +++ b/packages/core/src/__tests__/opencode-session-id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { asValidOpenCodeSessionId } from "../opencode-session-id.js"; + +describe("asValidOpenCodeSessionId", () => { + it("accepts valid OpenCode session ids", () => { + expect(asValidOpenCodeSessionId("ses_abc123")).toBe("ses_abc123"); + expect(asValidOpenCodeSessionId(" ses_ABC-123_xyz ")).toBe("ses_ABC-123_xyz"); + }); + + it("rejects invalid OpenCode session ids", () => { + expect(asValidOpenCodeSessionId("")).toBeUndefined(); + expect(asValidOpenCodeSessionId("ses bad")).toBeUndefined(); + expect(asValidOpenCodeSessionId("abc123")).toBeUndefined(); + expect(asValidOpenCodeSessionId(123)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/orchestrator-session-strategy.test.ts b/packages/core/src/__tests__/orchestrator-session-strategy.test.ts new file mode 100644 index 000000000..dd0632d88 --- /dev/null +++ b/packages/core/src/__tests__/orchestrator-session-strategy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeOrchestratorSessionStrategy } from "../orchestrator-session-strategy.js"; + +describe("normalizeOrchestratorSessionStrategy", () => { + it("defaults to reuse when strategy is unset", () => { + expect(normalizeOrchestratorSessionStrategy(undefined)).toBe("reuse"); + }); + + it("returns canonical strategies unchanged", () => { + expect(normalizeOrchestratorSessionStrategy("reuse")).toBe("reuse"); + expect(normalizeOrchestratorSessionStrategy("delete")).toBe("delete"); + expect(normalizeOrchestratorSessionStrategy("ignore")).toBe("ignore"); + }); + + it("maps legacy aliases to canonical values", () => { + expect(normalizeOrchestratorSessionStrategy("kill-previous")).toBe("delete"); + expect(normalizeOrchestratorSessionStrategy("delete-new")).toBe("delete"); + expect(normalizeOrchestratorSessionStrategy("ignore-new")).toBe("ignore"); + }); +}); diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts index 206bf9688..549f06309 100644 --- a/packages/core/src/__tests__/plugin-integration.test.ts +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -389,6 +389,30 @@ describe("plugin integration", () => { expect(result.skipped).toContain("app-1"); expect(result.killed).not.toContain("app-1"); }); + + it("list() clears enrichment timeout after fast enrichment", async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + + const registry = createTestRegistry(); + const sm = createSessionManager({ config, registry }); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/mock-ws/app-1", + branch: "feat/issue-99", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sessions = await sm.list("my-app"); + + expect(sessions).toHaveLength(1); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index e0132c4eb..d19a124c4 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -146,19 +146,75 @@ describe("loadBuiltins", () => { const fakeClaudeCode = makePlugin("agent", "claude-code"); const fakeCodex = makePlugin("agent", "codex"); + const fakeOpenCode = makePlugin("agent", "opencode"); await registry.loadBuiltins(undefined, async (pkg: string) => { if (pkg === "@composio/ao-plugin-agent-claude-code") return fakeClaudeCode; if (pkg === "@composio/ao-plugin-agent-codex") return fakeCodex; + if (pkg === "@composio/ao-plugin-agent-opencode") return fakeOpenCode; throw new Error(`Not found: ${pkg}`); }); const agents = registry.list("agent"); expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" })); expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" })); + expect(agents).toContainEqual(expect.objectContaining({ name: "opencode", slot: "agent" })); expect(registry.get("agent", "codex")).not.toBeNull(); expect(registry.get("agent", "claude-code")).not.toBeNull(); + expect(registry.get("agent", "opencode")).not.toBeNull(); + }); + + it("passes configured notifier plugin config to create()", async () => { + const registry = createPluginRegistry(); + const fakeWebhookNotifier = makePlugin("notifier", "webhook"); + const config = makeOrchestratorConfig({ + notifiers: { + webhook: { + plugin: "webhook", + url: "http://127.0.0.1:8787/hook", + retries: 2, + retryDelayMs: 500, + }, + }, + }); + + await registry.loadBuiltins(config, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhookNotifier; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({ + plugin: "webhook", + url: "http://127.0.0.1:8787/hook", + retries: 2, + retryDelayMs: 500, + }); + }); + + it("matches notifier config by plugin name instead of instance key", async () => { + const registry = createPluginRegistry(); + const fakeWebhookNotifier = makePlugin("notifier", "webhook"); + const config = makeOrchestratorConfig({ + notifiers: { + "my-webhook": { + plugin: "webhook", + url: "http://127.0.0.1:8787/custom-hook", + retries: 4, + }, + }, + }); + + await registry.loadBuiltins(config, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhookNotifier; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({ + plugin: "webhook", + url: "http://127.0.0.1:8787/custom-hook", + retries: 4, + }); }); }); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 386fd1c98..48dca8269 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir, tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -28,12 +28,73 @@ let mockAgent: Agent; let mockWorkspace: Workspace; let mockRegistry: PluginRegistry; let config: OrchestratorConfig; +let originalPath: string | undefined; function makeHandle(id: string): RuntimeHandle { return { id, runtimeName: "mock", data: {} }; } +function installMockOpencode( + sessionListJson: string, + deleteLogPath: string, + listDelaySeconds = 0, + listLogPath?: string, +): string { + const binDir = join(tmpDir, "mock-bin"); + mkdirSync(binDir, { recursive: true }); + const scriptPath = join(binDir, "opencode"); + writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [[ "$1" == "session" && "$2" == "list" ]]; then', + listLogPath ? ` printf '%s\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'` : "", + listDelaySeconds > 0 ? ` sleep ${listDelaySeconds}` : "", + ` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`, + " exit 0", + "fi", + 'if [[ "$1" == "session" && "$2" == "delete" ]]; then', + ` printf '%s\n' "$*" >> '${deleteLogPath.replace(/'/g, "'\\''")}'`, + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(scriptPath, 0o755); + return binDir; +} + +function installMockOpencodeWithNotFoundDelete(sessionListJson: string): string { + const binDir = join(tmpDir, "mock-bin-not-found"); + mkdirSync(binDir, { recursive: true }); + const scriptPath = join(binDir, "opencode"); + writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [[ "$1" == "session" && "$2" == "list" ]]; then', + ` printf '%s\n' '${sessionListJson.replace(/'/g, "'\\''")}'`, + " exit 0", + "fi", + 'if [[ "$1" == "session" && "$2" == "delete" ]]; then', + ' printf "Error: Session not found: %s\\n" "$3" >&2', + " exit 1", + "fi", + "exit 1", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(scriptPath, 0o755); + return binDir; +} + beforeEach(() => { + originalPath = process.env.PATH; tmpDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); @@ -123,6 +184,7 @@ beforeEach(() => { }); afterEach(() => { + process.env.PATH = originalPath; // Clean up hash-based directories in ~/.agent-orchestrator const projectBaseDir = getProjectBaseDir(configPath, join(tmpDir, "my-app")); if (existsSync(projectBaseDir)) { @@ -284,6 +346,248 @@ describe("spawn", () => { expect(meta!.issue).toBe("INT-42"); }); + it("reuses OpenCode session mapping by issue when available", async () => { + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + config = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + }, + }, + }; + + writeMetadata(sessionsDir, "app-9", { + worktree: "/tmp/old", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + opencodeSessionId: "ses_existing", + }); + + const sm = createSessionManager({ config, registry: registryWithOpenCode }); + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-42" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), + }), + }), + ); + + const metadata = readMetadataRaw(sessionsDir, session.id); + expect(metadata?.["opencodeSessionId"]).toBe("ses_existing"); + }); + + it("reuses most recent session-id candidate without relying on timestamps", async () => { + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + config = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + }, + }, + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/old-no-ts", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + opencodeSessionId: "ses_invalid_ts", + }); + + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp/new-with-ts", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + opencodeSessionId: "ses_valid_newer", + }); + + const sm = createSessionManager({ config, registry: registryWithOpenCode }); + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-42" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_valid_newer" }), + }), + }), + ); + + const metadata = readMetadataRaw(sessionsDir, session.id); + expect(metadata?.["opencodeSessionId"]).toBe("ses_valid_newer"); + }); + + it("does not reuse issue mapping when opencodeIssueSessionStrategy is ignore", async () => { + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + config = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + opencodeIssueSessionStrategy: "ignore", + }, + }, + }; + + writeMetadata(sessionsDir, "app-9", { + worktree: "/tmp/old", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + opencodeSessionId: "ses_existing", + }); + + const sm = createSessionManager({ config, registry: registryWithOpenCode }); + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-42" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }), + }), + }), + ); + + const metadata = readMetadataRaw(sessionsDir, session.id); + expect(metadata?.["opencodeSessionId"]).toBeUndefined(); + }); + + it("deletes old issue mappings and starts fresh when opencodeIssueSessionStrategy is delete", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-issue.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + config = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + opencodeIssueSessionStrategy: "delete", + }, + }, + }; + + writeMetadata(sessionsDir, "app-8", { + worktree: "/tmp/old1", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + opencodeSessionId: "ses_old_1", + }); + writeMetadata(sessionsDir, "app-9", { + worktree: "/tmp/old2", + branch: "feat/INT-42", + status: "killed", + project: "my-app", + issue: "INT-42", + agent: "opencode", + opencodeSessionId: "ses_old_2", + }); + + const sm = createSessionManager({ config, registry: registryWithOpenCode }); + const session = await sm.spawn({ projectId: "my-app", issueId: "INT-42" }); + + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_old_1"); + expect(deleteLog).toContain("session delete ses_old_2"); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }), + }), + }), + ); + + const metadata = readMetadataRaw(sessionsDir, session.id); + expect(metadata?.["opencodeSessionId"]).toBeUndefined(); + }); + it("throws for unknown project", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); await expect(sm.spawn({ projectId: "nonexistent" })).rejects.toThrow("Unknown project"); @@ -387,6 +691,56 @@ describe("spawn", () => { }); }); + it("forwards configured subagent to spawn launch when no override is provided", async () => { + const configWithSubagent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + subagent: "oracle", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithSubagent, + registry: mockRegistry, + }); + await sm.spawn({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ subagent: "oracle" }), + ); + }); + + it("prefers spawn subagent override over configured subagent", async () => { + const configWithSubagent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + subagent: "oracle", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithSubagent, + registry: mockRegistry, + }); + await sm.spawn({ projectId: "my-app", subagent: "librarian" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ subagent: "librarian" }), + ); + }); + it("validates issue exists when issueId provided", async () => { const mockTracker: Tracker = { name: "mock-tracker", @@ -714,6 +1068,28 @@ describe("list", () => { expect(sessions[0].id).toBe("app-1"); }); + it("clears enrichment timeout when enrichment completes quickly", async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "a", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list(); + + expect(sessions).toHaveLength(1); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); + it("marks dead runtimes as killed", async () => { const deadRuntime: Runtime = { ...mockRuntime, @@ -958,6 +1334,83 @@ describe("get", () => { expect(await sm.get("nonexistent")).toBeNull(); }); + it("auto-discovers and persists OpenCode session mapping when missing", async () => { + const deleteLogPath = join(tmpDir, "opencode-get-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_get_discovered", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const session = await sm.get("app-1"); + + expect(session).not.toBeNull(); + expect(session?.metadata["opencodeSessionId"]).toBe("ses_get_discovered"); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_get_discovered"); + }); + + it("reuses a single OpenCode session list lookup when multiple unmapped sessions are listed", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-list-shared.log"); + const listLogPath = join(tmpDir, "opencode-list-shared.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { id: "ses_get_discovered_1", title: "AO:app-1" }, + { id: "ses_get_discovered_2", title: "AO:app-2" }, + ]), + deleteLogPath, + 0, + listLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + writeMetadata(sessionsDir, "app-2", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list(); + + expect(sessions).toHaveLength(2); + expect(readMetadataRaw(sessionsDir, "app-1")?.["opencodeSessionId"]).toBe( + "ses_get_discovered_1", + ); + expect(readMetadataRaw(sessionsDir, "app-2")?.["opencodeSessionId"]).toBe( + "ses_get_discovered_2", + ); + + const listInvocations = readFileSync(listLogPath, "utf-8").trim().split("\n").filter(Boolean); + expect(listInvocations).toHaveLength(1); + }); it("preserves arbitrary metadata flags on loaded sessions", async () => { writeMetadata(sessionsDir, "app-1", { @@ -1044,6 +1497,24 @@ describe("kill", () => { expect(mockWorkspace.destroy).not.toHaveBeenCalled(); }); + it("does not destroy workspace when worktree resolves to project path", async () => { + const projectPath = config.projects["my-app"]?.path; + if (!projectPath) throw new Error("missing project path"); + + writeMetadata(sessionsDir, "app-1", { + worktree: `${projectPath}/`, + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-1"); + + expect(mockWorkspace.destroy).not.toHaveBeenCalled(); + }); + it("throws for nonexistent session", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); await expect(sm.kill("nonexistent")).rejects.toThrow("not found"); @@ -1075,6 +1546,70 @@ describe("kill", () => { // Should not throw even though runtime.destroy fails await expect(sm.kill("app-1")).resolves.toBeUndefined(); }); + + it("does not purge mapped OpenCode session on default kill", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-kill-default.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_keep", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-1"); + + expect(existsSync(deleteLogPath)).toBe(false); + }); + + it("purges mapped OpenCode session when requested", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-kill-purge.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_purge", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-1", { purgeOpenCode: true }); + + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_purge"); + }); + + it("skips purge when mapped OpenCode session id is invalid", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-kill-invalid.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/ws", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses bad id", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.kill("app-1", { purgeOpenCode: true }); + + expect(existsSync(deleteLogPath)).toBe(false); + }); }); describe("cleanup", () => { @@ -1121,6 +1656,228 @@ describe("cleanup", () => { expect(result.skipped).toHaveLength(0); }); + it("deletes mapped OpenCode session during cleanup", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const mockSCM: SCM = { + name: "mock-scm", + detectPR: vi.fn(), + getPRState: vi.fn().mockResolvedValue("merged"), + mergePR: vi.fn(), + closePR: vi.fn(), + getCIChecks: vi.fn(), + getCISummary: vi.fn(), + getReviews: vi.fn(), + getReviewDecision: vi.fn(), + getPendingComments: vi.fn(), + getAutomatedComments: vi.fn(), + getMergeability: vi.fn(), + }; + + const registryWithSCM: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + if (slot === "workspace") return mockWorkspace; + if (slot === "scm") return mockSCM; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "pr_open", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_cleanup", + pr: "https://github.com/org/repo/pull/10", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM }); + const result = await sm.cleanup(); + + expect(result.killed).toContain("app-1"); + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_cleanup"); + }); + + it("treats missing mapped OpenCode session as already cleaned", async () => { + const mockBin = installMockOpencodeWithNotFoundDelete("[]"); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const mockSCM: SCM = { + name: "mock-scm", + detectPR: vi.fn(), + getPRState: vi.fn().mockResolvedValue("merged"), + mergePR: vi.fn(), + closePR: vi.fn(), + getCIChecks: vi.fn(), + getCISummary: vi.fn(), + getReviews: vi.fn(), + getReviewDecision: vi.fn(), + getPendingComments: vi.fn(), + getAutomatedComments: vi.fn(), + getMergeability: vi.fn(), + }; + + const registryWithSCM: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + if (slot === "workspace") return mockWorkspace; + if (slot === "scm") return mockSCM; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "pr_open", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_missing", + pr: "https://github.com/org/repo/pull/10", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: registryWithSCM }); + const result = await sm.cleanup(); + + expect(result.killed).toContain("app-1"); + expect(result.errors).toEqual([]); + }); + + it("deletes mapped OpenCode session from archived killed sessions", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-archived.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-6", { + worktree: "/tmp", + branch: "main", + status: "spawning", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_archived", + runtimeHandle: JSON.stringify(makeHandle("rt-6")), + }); + deleteMetadata(sessionsDir, "app-6", true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const result = await sm.cleanup(); + + expect(result.killed).toContain("app-6"); + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_archived"); + }); + + it("does not skip archived cleanup for matching session IDs in other projects", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-archived-cross-project.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const project2Path = join(tmpDir, "my-app-2"); + const configWithSecondProject: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app-2": { + name: "My App 2", + repo: "org/my-app-2", + path: project2Path, + defaultBranch: "main", + sessionPrefix: "app", + scm: { plugin: "github" }, + tracker: { plugin: "github" }, + }, + }, + }; + const sessionsDir2 = getSessionsDir(configPath, project2Path); + mkdirSync(sessionsDir2, { recursive: true }); + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/project-1", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + writeMetadata(sessionsDir2, "app-1", { + worktree: "/tmp/project-2", + branch: "main", + status: "killed", + project: "my-app-2", + agent: "opencode", + opencodeSessionId: "ses_archived_project2", + runtimeHandle: JSON.stringify(makeHandle("rt-2")), + }); + deleteMetadata(sessionsDir2, "app-1", true); + + const sm = createSessionManager({ config: configWithSecondProject, registry: mockRegistry }); + const result = await sm.cleanup(); + + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_archived_project2"); + expect(result.killed).toContain("my-app-2:app-1"); + expect(result.skipped).toContain("my-app:app-1"); + }); + + it("skips invalid archived OpenCode session ids during cleanup", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-archived-invalid.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-8", { + worktree: "/tmp", + branch: "main", + status: "spawning", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses bad id", + runtimeHandle: JSON.stringify(makeHandle("rt-8")), + }); + deleteMetadata(sessionsDir, "app-8", true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const result = await sm.cleanup(); + + expect(result.killed).not.toContain("app-8"); + expect(result.errors).toEqual([]); + expect(result.skipped).toContain("app-8"); + expect(existsSync(deleteLogPath)).toBe(false); + }); + + it("does not delete archived OpenCode sessions in cleanup dry-run", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-archived-dry-run.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-7", { + worktree: "/tmp", + branch: "main", + status: "spawning", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_archived_dry_run", + runtimeHandle: JSON.stringify(makeHandle("rt-7")), + }); + deleteMetadata(sessionsDir, "app-7", true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const result = await sm.cleanup(undefined, { dryRun: true }); + + expect(result.killed).toContain("app-7"); + expect(existsSync(deleteLogPath)).toBe(false); + }); + it("skips sessions without merged PRs or completed issues", async () => { writeMetadata(sessionsDir, "app-1", { worktree: "/tmp", @@ -1239,9 +1996,7 @@ describe("send", () => { project: "my-app", runtimeHandle: JSON.stringify(makeHandle("rt-1")), }); - vi.mocked(mockRuntime.getOutput) - .mockResolvedValueOnce("before") - .mockResolvedValueOnce("after"); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValueOnce("after"); const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "Fix the CI failures"); @@ -1313,9 +2068,7 @@ describe("send", () => { status: "working", project: "my-app", }); - vi.mocked(mockRuntime.getOutput) - .mockResolvedValueOnce("before") - .mockResolvedValueOnce("after"); + vi.mocked(mockRuntime.getOutput).mockResolvedValueOnce("before").mockResolvedValueOnce("after"); const sm = createSessionManager({ config, registry: mockRegistry }); await sm.send("app-1", "hello"); @@ -1325,6 +2078,263 @@ describe("send", () => { "hello", ); }); + + it("auto-discovers OpenCode mapping before sending when missing", async () => { + const deleteLogPath = join(tmpDir, "opencode-send-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_send_discovered", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.send("app-1", "hello"); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_send_discovered"); + expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "hello"); + }); + + it("re-discovers OpenCode mapping before sending when stored mapping is invalid", async () => { + const deleteLogPath = join(tmpDir, "opencode-send-remap-invalid.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_send_discovered_valid", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses bad id", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.send("app-1", "hello"); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_send_discovered_valid"); + expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "hello"); + }); +}); + +describe("remap", () => { + it("returns persisted OpenCode session id", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + opencodeSessionId: "ses_remap", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1"); + + expect(mapped).toBe("ses_remap"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_remap"); + }); + + it("refreshes mapping when force remap is requested", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-force-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_fresh", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + opencodeSessionId: "ses_stale", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1", true); + + expect(mapped).toBe("ses_fresh"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_fresh"); + }); + + it("uses a longer discovery timeout for explicit remap operations", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-slow-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_slow_discovery", + title: "AO:app-1", + }, + ]), + deleteLogPath, + 3, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1", true); + + expect(mapped).toBe("ses_slow_discovery"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_slow_discovery"); + }); + + it("throws when OpenCode session id mapping is missing", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.remap("app-1")).rejects.toThrow("mapping is missing"); + }); + + it("discovers mapping by AO session title and persists it", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_discovered", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1"); + + expect(mapped).toBe("ses_discovered"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_discovered"); + }); + + it("falls back to title discovery when persisted mapping is invalid", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-remap-invalid.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_discovered_valid", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + opencodeSessionId: "ses bad id", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1"); + + expect(mapped).toBe("ses_discovered_valid"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_valid"); + }); + + it("uses the project agent fallback when metadata does not persist the agent name", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-remap-project-agent.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_project_agent", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + config.projects["my-app"] = { + ...config.projects["my-app"]!, + agent: "opencode", + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const mapped = await sm.remap("app-1"); + + expect(mapped).toBe("ses_project_agent"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_project_agent"); + }); }); describe("spawnOrchestrator", () => { @@ -1356,6 +2366,432 @@ describe("spawnOrchestrator", () => { expect(meta!.runtimeHandle).toBeDefined(); }); + it("deletes previous OpenCode orchestrator sessions before starting", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, + { id: "ses_new", title: "AO:app-orchestrator", updated: "2025-01-02T00:00:00.000Z" }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithDelete: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "delete", + }, + }, + }; + + const sm = createSessionManager({ config: configWithDelete, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const deleteLog = readFileSync(deleteLogPath, "utf-8"); + expect(deleteLog).toContain("session delete ses_old"); + expect(deleteLog).toContain("session delete ses_new"); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "app-orchestrator", + projectConfig: expect.objectContaining({ + agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }), + }), + }), + ); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["agent"]).toBe("opencode"); + expect(meta?.["opencodeSessionId"]).toBeUndefined(); + }); + + it("discovers and persists OpenCode session id by title when strategy is reuse", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-discovery.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_discovered_orchestrator", + title: "AO:app-orchestrator", + updated: 1_772_777_000_000, + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator"); + }); + + it("reuses an existing orchestrator session when strategy is reuse", async () => { + const listLogPath = join(tmpDir, "opencode-list-orchestrator-reuse.log"); + const mockBin = join(tmpDir, "mock-bin-reuse-no-list"); + mkdirSync(mockBin, { recursive: true }); + const scriptPath = join(mockBin, "opencode"); + writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [[ "$1" == "session" && "$2" == "list" ]]; then', + ` printf '%s\\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'`, + " printf '[]\\n'", + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + "utf-8", + ); + chmodSync(scriptPath, 0o755); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + opencodeSessionId: "ses_existing", + createdAt: new Date().toISOString(), + }); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.metadata["orchestratorSessionReused"]).toBe("true"); + expect(mockRuntime.create).not.toHaveBeenCalled(); + expect(mockRuntime.destroy).not.toHaveBeenCalled(); + expect(existsSync(listLogPath)).toBe(false); + }); + + it("reuses mapped OpenCode session id when strategy is reuse and runtime is restarted", async () => { + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + opencodeSessionId: "ses_existing", + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), + }), + }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["opencodeSessionId"]).toBe("ses_existing"); + }); + + it("reuses archived OpenCode mapping for orchestrator when active metadata has no mapping", async () => { + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + opencodeSessionId: "ses_existing", + createdAt: new Date().toISOString(), + }); + deleteMetadata(sessionsDir, "app-orchestrator", true); + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), + }), + }), + ); + }); + + it("reuses OpenCode session by title when orchestrator mapping is missing", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + null, + { id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_title_match" }), + }), + }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["opencodeSessionId"]).toBe("ses_title_match"); + }); + + it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithIgnoreNew: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "ignore", + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: join(tmpDir, "my-app"), + branch: "main", + status: "working", + role: "orchestrator", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-existing")), + createdAt: new Date().toISOString(), + }); + + vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(true); + + const sm = createSessionManager({ + config: configWithIgnoreNew, + registry: registryWithOpenCode, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-existing")); + expect(mockRuntime.create).toHaveBeenCalled(); + expect(existsSync(deleteLogPath)).toBe(false); + }); + it("skips workspace creation", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -1401,6 +2837,66 @@ describe("spawnOrchestrator", () => { ); }); + it("does not persist orchestratorSessionReused metadata on newly created sessions", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["orchestratorSessionReused"]).toBeUndefined(); + }); + + it("uses orchestratorModel when configured", async () => { + const configWithOrchestratorModel: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + model: "worker-model", + orchestratorModel: "orchestrator-model", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithOrchestratorModel, + registry: mockRegistry, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ model: "orchestrator-model" }), + ); + }); + + it("forwards configured subagent to orchestrator launch", async () => { + const configWithSubagent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + subagent: "oracle", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithSubagent, + registry: mockRegistry, + }); + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ subagent: "oracle" }), + ); + }); + it("writes system prompt to file and passes systemPromptFile to agent", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -1701,6 +3197,154 @@ describe("restore", () => { await expect(sm.restore("nonexistent")).rejects.toThrow("not found"); }); + it("does not recreate active metadata when archive restore fails validation", async () => { + const wsPath = join(tmpDir, "ws-app-1"); + mkdirSync(wsPath, { recursive: true }); + const deleteLogPath = join(tmpDir, "opencode-restore-validation.log"); + const mockBin = installMockOpencode("[]", deleteLogPath); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + agent: "opencode", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + deleteMetadata(sessionsDir, "app-1"); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError); + + expect(readMetadataRaw(sessionsDir, "app-1")).toBeNull(); + }); + + it("does not recreate active metadata from archive when session is not restorable", async () => { + const wsPath = join(tmpDir, "ws-app-archive-non-restorable"); + mkdirSync(wsPath, { recursive: true }); + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "main", + status: "working", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_archive_valid", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + deleteMetadata(sessionsDir, "app-1", true); + + const sm = createSessionManager({ config, registry: mockRegistry }); + await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError); + + expect(readMetadataRaw(sessionsDir, "app-1")).toBeNull(); + }); + + it("re-discovers OpenCode mapping when stored mapping is invalid", async () => { + const wsPath = join(tmpDir, "ws-app-restore-invalid-map"); + mkdirSync(wsPath, { recursive: true }); + const deleteLogPath = join(tmpDir, "opencode-restore-invalid-remap.log"); + const mockBin = installMockOpencode( + JSON.stringify([ + { + id: "ses_restore_discovered", + title: "AO:app-1", + }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses bad id", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const restored = await sm.restore("app-1"); + + expect(restored.status).toBe("spawning"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_restore_discovered"); + }); + + it("uses orchestratorModel when restoring orchestrator sessions", async () => { + const wsPath = join(tmpDir, "ws-app-orchestrator-restore"); + mkdirSync(wsPath, { recursive: true }); + + const configWithOrchestratorModel: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + model: "worker-model", + orchestratorModel: "orchestrator-model", + }, + }, + }, + }; + + writeMetadata(sessionsDir, "app-orchestrator", { + worktree: wsPath, + branch: "main", + status: "killed", + project: "my-app", + role: "orchestrator", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const sm = createSessionManager({ + config: configWithOrchestratorModel, + registry: mockRegistry, + }); + await sm.restore("app-orchestrator"); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ model: "orchestrator-model" }), + ); + }); + + it("forwards configured subagent when restoring sessions", async () => { + const wsPath = join(tmpDir, "ws-app-restore-subagent"); + mkdirSync(wsPath, { recursive: true }); + + const configWithSubagent: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agentConfig: { + subagent: "oracle", + }, + }, + }, + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-SUBAGENT", + status: "killed", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const sm = createSessionManager({ config: configWithSubagent, registry: mockRegistry }); + await sm.restore("app-1"); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ subagent: "oracle" }), + ); + }); + it("uses getRestoreCommand when available", async () => { const wsPath = join(tmpDir, "ws-app-1"); mkdirSync(wsPath, { recursive: true }); @@ -1800,6 +3444,83 @@ describe("restore", () => { expect(meta!["summary"]).toBe("Implementing feature X"); expect(meta!["branch"]).toBe("feat/TEST-42"); }); + + it("does not overwrite restored status/runtime metadata when postLaunchSetup is a no-op", async () => { + const wsPath = join(tmpDir, "ws-app-post-launch-noop"); + mkdirSync(wsPath, { recursive: true }); + + const agentWithNoopPostLaunch: Agent = { + ...mockAgent, + postLaunchSetup: vi.fn().mockResolvedValue(undefined), + }; + + const registryWithNoopPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithNoopPostLaunch; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-77", + status: "killed", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const sm = createSessionManager({ config, registry: registryWithNoopPostLaunch }); + await sm.restore("app-1"); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta!["status"]).toBe("spawning"); + expect(meta!["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1"))); + expect(meta!["restoredAt"]).toBeDefined(); + }); + + it("persists only metadata updates produced by postLaunchSetup", async () => { + const wsPath = join(tmpDir, "ws-app-post-launch-metadata"); + mkdirSync(wsPath, { recursive: true }); + + const agentWithMetadataUpdate: Agent = { + ...mockAgent, + postLaunchSetup: vi.fn().mockImplementation(async (session) => { + session.metadata = { + ...session.metadata, + opencodeSessionId: "ses_from_post_launch", + }; + }), + }; + + const registryWithMetadataUpdate: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithMetadataUpdate; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-78", + status: "killed", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const sm = createSessionManager({ config, registry: registryWithMetadataUpdate }); + await sm.restore("app-1"); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta!["status"]).toBe("spawning"); + expect(meta!["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1"))); + expect(meta!["opencodeSessionId"]).toBe("ses_from_post_launch"); + }); }); describe("claimPR", () => { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f9e92288f..1607615ca 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -60,6 +60,8 @@ const AgentSpecificConfigSchema = z .object({ permissions: AgentPermissionSchema, model: z.string().optional(), + orchestratorModel: z.string().optional(), + opencodeSessionId: z.string().optional(), }) .passthrough(); @@ -84,6 +86,10 @@ const ProjectConfigSchema = z.object({ agentRules: z.string().optional(), agentRulesFile: z.string().optional(), orchestratorRules: z.string().optional(), + orchestratorSessionStrategy: z + .enum(["reuse", "delete", "ignore", "delete-new", "ignore-new", "kill-previous"]) + .optional(), + opencodeIssueSessionStrategy: z.enum(["reuse", "delete", "ignore"]).optional(), }); const DefaultPluginsSchema = z.object({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2f504fbdc..b97f7aa49 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,9 @@ export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; // Shared utilities export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js"; +export { asValidOpenCodeSessionId } from "./opencode-session-id.js"; +export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; +export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; // Path utilities — hash-based directory structure export { diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 0bf57ee3a..1247b9e9c 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -120,6 +120,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta directTerminalWsPort: raw["directTerminalWsPort"] ? Number(raw["directTerminalWsPort"]) : undefined, + opencodeSessionId: raw["opencodeSessionId"], }; } @@ -168,6 +169,7 @@ export function writeMetadata( data["terminalWsPort"] = String(metadata.terminalWsPort); if (metadata.directTerminalWsPort !== undefined) data["directTerminalWsPort"] = String(metadata.directTerminalWsPort); + if (metadata.opencodeSessionId) data["opencodeSessionId"] = metadata.opencodeSessionId; atomicWriteFileSync(path, serializeMetadata(data)); } @@ -258,6 +260,49 @@ export function readArchivedMetadataRaw( } } +export function updateArchivedMetadata( + dataDir: string, + sessionId: SessionId, + updates: Partial>, +): boolean { + validateSessionId(sessionId); + const archiveDir = join(dataDir, "archive"); + if (!existsSync(archiveDir)) return false; + + const prefix = `${sessionId}_`; + let latest: string | null = null; + + for (const file of readdirSync(archiveDir)) { + if (!file.startsWith(prefix)) continue; + const charAfterPrefix = file[prefix.length]; + if (!charAfterPrefix || charAfterPrefix < "0" || charAfterPrefix > "9") continue; + if (!latest || file > latest) latest = file; + } + + if (!latest) return false; + + const archivePath = join(archiveDir, latest); + let existing: Record; + try { + existing = parseMetadataFile(readFileSync(archivePath, "utf-8")); + } catch { + return false; + } + + for (const [key, value] of Object.entries(updates)) { + if (value === undefined) continue; + if (value === "") { + const { [key]: _, ...rest } = existing; + existing = rest; + } else { + existing[key] = value; + } + } + + atomicWriteFileSync(archivePath, serializeMetadata(existing)); + return true; +} + /** * List all session IDs that have metadata files. */ diff --git a/packages/core/src/opencode-session-id.ts b/packages/core/src/opencode-session-id.ts new file mode 100644 index 000000000..f8a08034a --- /dev/null +++ b/packages/core/src/opencode-session-id.ts @@ -0,0 +1,8 @@ +const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/; + +export function asValidOpenCodeSessionId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined; +} diff --git a/packages/core/src/orchestrator-session-strategy.ts b/packages/core/src/orchestrator-session-strategy.ts new file mode 100644 index 000000000..0d2c2b490 --- /dev/null +++ b/packages/core/src/orchestrator-session-strategy.ts @@ -0,0 +1,11 @@ +import type { ProjectConfig } from "./types.js"; + +export type NormalizedOrchestratorSessionStrategy = "reuse" | "delete" | "ignore"; + +export function normalizeOrchestratorSessionStrategy( + strategy: ProjectConfig["orchestratorSessionStrategy"] | undefined, +): NormalizedOrchestratorSessionStrategy { + if (strategy === "kill-previous" || strategy === "delete-new") return "delete"; + if (strategy === "ignore-new") return "ignore"; + return strategy ?? "reuse"; +} diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index f4263991e..e1b655337 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -31,6 +31,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "agent", name: "claude-code", pkg: "@composio/ao-plugin-agent-claude-code" }, { slot: "agent", name: "codex", pkg: "@composio/ao-plugin-agent-codex" }, { slot: "agent", name: "aider", pkg: "@composio/ao-plugin-agent-aider" }, + { slot: "agent", name: "opencode", pkg: "@composio/ao-plugin-agent-opencode" }, // Workspaces { slot: "workspace", name: "worktree", pkg: "@composio/ao-plugin-workspace-worktree" }, { slot: "workspace", name: "clone", pkg: "@composio/ao-plugin-workspace-clone" }, @@ -51,11 +52,22 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = /** Extract plugin-specific config from orchestrator config */ function extractPluginConfig( - _slot: PluginSlot, - _name: string, - _config: OrchestratorConfig, + slot: PluginSlot, + name: string, + config: OrchestratorConfig, ): Record | undefined { - // Reserved for future plugin-specific config mapping + if (slot === "notifier") { + for (const notifierConfig of Object.values(config.notifiers)) { + if ( + notifierConfig && + typeof notifierConfig === "object" && + notifierConfig["plugin"] === name + ) { + return notifierConfig; + } + } + } + return undefined; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 1e55b2090..ba325c809 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -12,15 +12,18 @@ */ import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs"; +import { execFile } from "node:child_process"; import { basename, join, resolve } from "node:path"; import { homedir } from "node:os"; +import { promisify } from "node:util"; import { isIssueNotFoundError, isRestorable, NON_RESTORABLE_STATUSES, + SessionNotFoundError, SessionNotRestorableError, WorkspaceMissingError, - type SessionManager, + type OpenCodeSessionManager, type Session, type SessionId, type SessionSpawnConfig, @@ -44,6 +47,7 @@ import { import { readMetadataRaw, readArchivedMetadataRaw, + updateArchivedMetadata, writeMetadata, updateMetadata, deleteMetadata, @@ -59,6 +63,88 @@ import { generateConfigHash, validateAndStoreOrigin, } from "./paths.js"; +import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; +import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; + +const execFileAsync = promisify(execFile); +const OPENCODE_DISCOVERY_TIMEOUT_MS = 2_000; +const OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS = 10_000; + +function errorIncludesSessionNotFound(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { stderr?: string; stdout?: string }; + const combined = [err.message, e.stderr, e.stdout].filter(Boolean).join("\n"); + return /session not found/i.test(combined); +} + +async function deleteOpenCodeSession(sessionId: string): Promise { + const validatedSessionId = asValidOpenCodeSessionId(sessionId); + if (!validatedSessionId) return; + const retryDelaysMs = [0, 200, 600]; + let lastError: unknown; + for (const delayMs of retryDelaysMs) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + try { + await execFileAsync("opencode", ["session", "delete", validatedSessionId], { + timeout: 30_000, + }); + return; + } catch (err) { + if (errorIncludesSessionNotFound(err)) { + return; + } + lastError = err; + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +interface OpenCodeSessionListEntry { + id: string; + title: string; +} + +async function fetchOpenCodeSessionList( + timeoutMs = OPENCODE_DISCOVERY_TIMEOUT_MS, +): Promise { + try { + const { stdout } = await execFileAsync("opencode", ["session", "list", "--format", "json"], { + timeout: timeoutMs, + }); + const parsed = safeJsonParse(stdout); + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const title = typeof entry["title"] === "string" ? entry["title"] : ""; + const id = asValidOpenCodeSessionId(entry["id"]); + return id ? [{ id, title }] : []; + }); + } catch { + return []; + } +} + +async function discoverOpenCodeSessionIdsByTitle( + sessionId: string, + timeoutMs = OPENCODE_DISCOVERY_TIMEOUT_MS, + sessionListPromise?: Promise, +): Promise { + const sessions = await (sessionListPromise ?? fetchOpenCodeSessionList(timeoutMs)); + const title = `AO:${sessionId}`; + return sessions.filter((entry) => entry.title === title).map((entry) => entry.id); +} + +async function discoverOpenCodeSessionIdByTitle( + sessionId: string, + timeoutMs?: number, + sessionListPromise?: Promise, +): Promise { + const matches = await discoverOpenCodeSessionIdsByTitle(sessionId, timeoutMs, sessionListPromise); + return matches[0]; +} /** Escape regex metacharacters in a string. */ function escapeRegex(str: string): string { @@ -186,7 +272,7 @@ export interface SessionManagerDeps { } /** Create a SessionManager instance. */ -export function createSessionManager(deps: SessionManagerDeps): SessionManager { +export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionManager { const { config, registry } = deps; interface LocatedSession { @@ -277,6 +363,106 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return results; } + function listArchivedSessionIds(sessionsDir: string): string[] { + const archiveDir = join(sessionsDir, "archive"); + if (!existsSync(archiveDir)) return []; + const ids = new Set(); + for (const file of readdirSync(archiveDir)) { + const match = file.match(/^([a-zA-Z0-9_-]+)_\d/); + if (match?.[1]) ids.add(match[1]); + } + return [...ids]; + } + + function markArchivedOpenCodeCleanup(sessionsDir: string, sessionId: SessionId): void { + updateArchivedMetadata(sessionsDir, sessionId, { + opencodeSessionId: "", + opencodeCleanedAt: new Date().toISOString(), + }); + } + + function sortSessionIdsForReuse(ids: string[]): string[] { + const numericSuffix = (id: string): number | undefined => { + const match = id.match(/-(\d+)$/); + if (!match) return undefined; + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? undefined : parsed; + }; + + return [...ids].sort((a, b) => { + const aNum = numericSuffix(a); + const bNum = numericSuffix(b); + if (aNum !== undefined && bNum !== undefined && aNum !== bNum) { + return bNum - aNum; + } + if (aNum !== undefined && bNum === undefined) return -1; + if (aNum === undefined && bNum !== undefined) return 1; + return b.localeCompare(a); + }); + } + + function findOpenCodeSessionIds( + sessionsDir: string, + criteria: { issueId?: string; sessionId?: string }, + ): string[] { + const matchesCriteria = (id: string, raw: Record | null): boolean => { + if (!raw) return false; + if (raw["agent"] !== "opencode") return false; + if (criteria.issueId !== undefined && raw["issue"] !== criteria.issueId) return false; + if (criteria.sessionId !== undefined && id !== criteria.sessionId) return false; + return true; + }; + + const ids: string[] = []; + const maybeAdd = (id: string, raw: Record | null) => { + if (!matchesCriteria(id, raw)) return; + const mapped = asValidOpenCodeSessionId(raw?.["opencodeSessionId"]); + if (!mapped) return; + ids.push(mapped); + }; + + for (const id of sortSessionIdsForReuse(listMetadata(sessionsDir))) { + maybeAdd(id, readMetadataRaw(sessionsDir, id)); + } + for (const id of sortSessionIdsForReuse(listArchivedSessionIds(sessionsDir))) { + maybeAdd(id, readArchivedMetadataRaw(sessionsDir, id)); + } + + return [...new Set(ids)]; + } + + async function resolveOpenCodeSessionReuse(options: { + sessionsDir: string; + criteria: { issueId?: string; sessionId?: string }; + strategy: "reuse" | "delete" | "ignore"; + includeTitleDiscoveryForSessionId?: boolean; + }): Promise { + const { sessionsDir, criteria, strategy, includeTitleDiscoveryForSessionId = false } = options; + if (strategy === "ignore") return undefined; + + let candidateIds = findOpenCodeSessionIds(sessionsDir, criteria); + + if (strategy === "delete") { + if (includeTitleDiscoveryForSessionId && criteria.sessionId) { + candidateIds = [ + ...candidateIds, + ...(await discoverOpenCodeSessionIdsByTitle(criteria.sessionId)), + ]; + } + + for (const openCodeSessionId of [...new Set(candidateIds)]) { + await deleteOpenCodeSession(openCodeSessionId); + } + return undefined; + } + + if (candidateIds.length === 0 && criteria.sessionId) { + candidateIds = await discoverOpenCodeSessionIdsByTitle(criteria.sessionId); + } + + return candidateIds[0]; + } + /** Resolve which plugins to use for a project. */ function resolvePlugins(project: ProjectConfig, agentOverride?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); @@ -296,6 +482,27 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return { runtime, agent, workspace, tracker, scm }; } + async function ensureOpenCodeSessionMapping( + session: Session, + sessionName: string, + sessionsDir: string, + effectiveAgentName: string, + sessionListPromise?: Promise, + ): Promise { + if (effectiveAgentName !== "opencode") return; + if (asValidOpenCodeSessionId(session.metadata["opencodeSessionId"])) return; + + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionName, + OPENCODE_DISCOVERY_TIMEOUT_MS, + sessionListPromise, + ); + if (!discovered) return; + + session.metadata["opencodeSessionId"] = discovered; + updateMetadata(sessionsDir, sessionName, { opencodeSessionId: discovered }); + } + function findSessionRecord(sessionId: SessionId): LocatedSession | null { for (const [projectId, project] of Object.entries(config.projects)) { const sessionsDir = getProjectSessionsDir(project); @@ -307,6 +514,14 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return null; } + function requireSessionRecord(sessionId: SessionId): LocatedSession { + const located = findSessionRecord(sessionId); + if (!located) { + throw new SessionNotFoundError(sessionId); + } + return located; + } + /** * Ensure session has a runtime handle (fabricate one if missing) and enrich * with live runtime state + activity detection. Used by both list() and get(). @@ -314,9 +529,20 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { async function ensureHandleAndEnrich( session: Session, sessionName: string, + sessionsDir: string, project: ProjectConfig, + effectiveAgentName: string, plugins: ReturnType, + sessionListPromise?: Promise, ): Promise { + await ensureOpenCodeSessionMapping( + session, + sessionName, + sessionsDir, + effectiveAgentName, + sessionListPromise, + ); + const handleFromMetadata = session.runtimeHandle !== null; if (!handleFromMetadata) { session.runtimeHandle = { @@ -548,13 +774,34 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }); // Get agent launch config and create runtime — clean up workspace on failure + const opencodeIssueSessionStrategy = project.opencodeIssueSessionStrategy ?? "reuse"; + const reusedOpenCodeSessionId = + plugins.agent.name === "opencode" && spawnConfig.issueId + ? await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { issueId: spawnConfig.issueId }, + strategy: opencodeIssueSessionStrategy, + }) + : undefined; + const configuredSubagent = + typeof project.agentConfig?.["subagent"] === "string" + ? project.agentConfig["subagent"] + : undefined; + const agentLaunchConfig = { sessionId, - projectConfig: project, + projectConfig: { + ...project, + agentConfig: { + ...(project.agentConfig ?? {}), + ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}), + }, + }, issueId: spawnConfig.issueId, prompt: composedPrompt, permissions: project.agentConfig?.permissions, model: project.agentConfig?.model, + subagent: spawnConfig.subagent ?? configuredSubagent, }; let handle: RuntimeHandle; @@ -608,7 +855,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { agentInfo: null, createdAt: new Date(), lastActivityAt: new Date(), - metadata: {}, + metadata: { + ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}), + }, }; try { @@ -622,11 +871,30 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { agent: plugins.agent.name, // Persist agent name for lifecycle manager createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), + opencodeSessionId: reusedOpenCodeSessionId, }); if (plugins.agent.postLaunchSetup) { await plugins.agent.postLaunchSetup(session); } + + if ( + plugins.agent.name === "opencode" && + opencodeIssueSessionStrategy === "reuse" && + !session.metadata["opencodeSessionId"] + ) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (discovered) { + session.metadata["opencodeSessionId"] = discovered; + } + } + + if (Object.keys(session.metadata || {}).length > 0) { + updateMetadata(sessionsDir, sessionId, session.metadata); + } } catch (err) { // Clean up runtime and workspace on post-launch failure try { @@ -685,6 +953,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } const sessionId = `${project.sessionPrefix}-orchestrator`; + const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( + project.orchestratorSessionStrategy, + ); // Generate tmux name if using new architecture let tmuxName: string | undefined; @@ -717,14 +988,57 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); } + const existingOrchestrator = await get(sessionId); + if (existingOrchestrator?.runtimeHandle) { + const existingAlive = await plugins.runtime + .isAlive(existingOrchestrator.runtimeHandle) + .catch(() => false); + if (existingAlive && orchestratorSessionStrategy === "reuse") { + existingOrchestrator.metadata["orchestratorSessionReused"] = "true"; + return existingOrchestrator; + } + if (existingAlive && orchestratorSessionStrategy !== "reuse") { + await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); + } + } + + const reusableOpenCodeSessionId = + plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" + ? await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "reuse", + }) + : undefined; + const configuredSubagent = + typeof project.agentConfig?.["subagent"] === "string" + ? project.agentConfig["subagent"] + : undefined; + + if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { + await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "delete", + includeTitleDiscoveryForSessionId: true, + }); + } + // Get agent launch config — uses systemPromptFile, no issue/tracker interaction. // Orchestrator ALWAYS gets permissionless mode — it must run ao CLI commands autonomously. const agentLaunchConfig = { sessionId, - projectConfig: project, + projectConfig: { + ...project, + agentConfig: { + ...(project.agentConfig ?? {}), + ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}), + }, + }, permissions: "permissionless" as const, - model: project.agentConfig?.model, + model: project.agentConfig?.orchestratorModel ?? project.agentConfig?.model, systemPromptFile, + subagent: configuredSubagent, }; const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); @@ -757,7 +1071,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { agentInfo: null, createdAt: new Date(), lastActivityAt: new Date(), - metadata: {}, + metadata: { + ...(reusableOpenCodeSessionId ? { opencodeSessionId: reusableOpenCodeSessionId } : {}), + }, }; try { @@ -768,13 +1084,33 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { role: "orchestrator", tmuxName, project: orchestratorConfig.projectId, + agent: plugins.agent.name, createdAt: new Date().toISOString(), runtimeHandle: JSON.stringify(handle), + opencodeSessionId: reusableOpenCodeSessionId, }); if (plugins.agent.postLaunchSetup) { await plugins.agent.postLaunchSetup(session); } + + if ( + plugins.agent.name === "opencode" && + orchestratorSessionStrategy === "reuse" && + !session.metadata["opencodeSessionId"] + ) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (discovered) { + session.metadata["opencodeSessionId"] = discovered; + } + } + + if (Object.keys(session.metadata || {}).length > 0) { + updateMetadata(sessionsDir, sessionId, session.metadata); + } } catch (err) { // Clean up runtime on post-launch failure try { @@ -795,45 +1131,62 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { async function list(projectId?: string): Promise { const allSessions = listAllSessions(projectId); + let openCodeSessionListPromise: Promise | undefined; - const sessionPromises = allSessions.map( - async ({ sessionName, projectId: sessionProjectId }) => { - const project = config.projects[sessionProjectId]; - if (!project) return null; + const tasks = allSessions.map(async ({ sessionName, projectId: sessionProjectId }) => { + const project = config.projects[sessionProjectId]; + if (!project) return null; - const sessionsDir = getProjectSessionsDir(project); - const raw = readMetadataRaw(sessionsDir, sessionName); - if (!raw) return null; + const sessionsDir = getProjectSessionsDir(project); + const raw = readMetadataRaw(sessionsDir, sessionName); + if (!raw) return null; - // Get file timestamps for createdAt/lastActivityAt - let createdAt: Date | undefined; - let modifiedAt: Date | undefined; - try { - const metaPath = join(sessionsDir, sessionName); - const stats = statSync(metaPath); - createdAt = stats.birthtime; - modifiedAt = stats.mtime; - } catch { - // If stat fails, timestamps will fall back to current time + let createdAt: Date | undefined; + let modifiedAt: Date | undefined; + try { + const metaPath = join(sessionsDir, sessionName); + const stats = statSync(metaPath); + createdAt = stats.birthtime; + modifiedAt = stats.mtime; + } catch { + // If stat fails, timestamps will fall back to current time + } + + const session = metadataToSession(sessionName, raw, createdAt, modifiedAt); + const selectedAgentName = raw["agent"]; + const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; + const plugins = resolvePlugins(project, effectiveAgentName); + const sessionListPromise = + effectiveAgentName === "opencode" + ? (openCodeSessionListPromise ??= fetchOpenCodeSessionList()) + : undefined; + + let enrichTimeoutId: ReturnType | null = null; + const enrichTimeout = new Promise((resolve) => { + enrichTimeoutId = setTimeout(resolve, 2_000); + }); + const enrichPromise = ensureHandleAndEnrich( + session, + sessionName, + sessionsDir, + project, + effectiveAgentName, + plugins, + sessionListPromise, + ).catch(() => {}); + try { + await Promise.race([enrichPromise, enrichTimeout]); + } finally { + if (enrichTimeoutId) { + clearTimeout(enrichTimeoutId); } + } - const session = metadataToSession(sessionName, raw, createdAt, modifiedAt); + return session; + }); - const plugins = resolvePlugins(project, raw["agent"]); - // Cap per-session enrichment at 2s — subprocess calls (tmux/ps) can be - // slow under load. If we time out, session keeps its metadata values. - const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 2_000)); - await Promise.race([ - ensureHandleAndEnrich(session, sessionName, project, plugins), - enrichTimeout, - ]); - - return session; - }, - ); - - const results = await Promise.all(sessionPromises); - return results.filter((s): s is Session => s !== null); + const resolved = await Promise.all(tasks); + return resolved.filter((session): session is Session => session !== null); } async function get(sessionId: SessionId): Promise { @@ -857,8 +1210,17 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); - const plugins = resolvePlugins(project, raw["agent"]); - await ensureHandleAndEnrich(session, sessionId, project, plugins); + const selectedAgentName = raw["agent"]; + const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent; + const plugins = resolvePlugins(project, effectiveAgentName); + await ensureHandleAndEnrich( + session, + sessionId, + sessionsDir, + project, + effectiveAgentName, + plugins, + ); return session; } @@ -866,28 +1228,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return null; } - async function kill(sessionId: SessionId): Promise { - // Find the session in any project's sessions directory - let raw: Record | null = null; - let sessionsDir: string | null = null; - let project: ProjectConfig | undefined; - let projectId: string | undefined; + async function kill(sessionId: SessionId, options?: { purgeOpenCode?: boolean }): Promise { + const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); - for (const [projId, proj] of Object.entries(config.projects)) { - const dir = getProjectSessionsDir(proj); - const metadata = readMetadataRaw(dir, sessionId); - if (metadata) { - raw = metadata; - sessionsDir = dir; - project = proj; - projectId = projId; - break; - } - } - - if (!raw || !sessionsDir) { - throw new Error(`Session ${sessionId} not found`); - } + const cleanupAgent = raw["agent"] ?? project?.agent ?? config.defaults.agent; // Destroy runtime — prefer handle.runtimeName to find the correct plugin if (raw["runtimeHandle"]) { @@ -922,16 +1266,70 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } } + let didPurgeOpenCodeSession = false; + if (options?.purgeOpenCode === true && cleanupAgent === "opencode") { + const mappedOpenCodeSessionId = + asValidOpenCodeSessionId(raw["opencodeSessionId"]) ?? + (await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + )); + + if (mappedOpenCodeSessionId) { + try { + await deleteOpenCodeSession(mappedOpenCodeSessionId); + didPurgeOpenCodeSession = true; + } catch { + void 0; + } + } + } + // Archive metadata deleteMetadata(sessionsDir, sessionId, true); + if (didPurgeOpenCodeSession) { + markArchivedOpenCodeCleanup(sessionsDir, sessionId); + } } async function cleanup( projectId?: string, - options?: { dryRun?: boolean }, + options?: { dryRun?: boolean; purgeOpenCode?: boolean }, ): Promise { const result: CleanupResult = { killed: [], skipped: [], errors: [] }; const sessions = await list(projectId); + const activeSessionKeys = new Set( + sessions.map((session) => `${session.projectId}:${session.id}`), + ); + + const killedKeys = new Set(); + const skippedKeys = new Set(); + + const toEntryKey = (entryProjectId: string, id: string): string => `${entryProjectId}:${id}`; + const fromEntryKey = (entryKey: string): { projectId: string; id: string } => { + const separatorIndex = entryKey.indexOf(":"); + if (separatorIndex === -1) { + return { projectId: "", id: entryKey }; + } + return { + projectId: entryKey.slice(0, separatorIndex), + id: entryKey.slice(separatorIndex + 1), + }; + }; + + const pushKilled = (entryProjectId: string, id: string): void => { + const key = toEntryKey(entryProjectId, id); + skippedKeys.delete(key); + killedKeys.add(key); + }; + + const pushSkipped = (entryProjectId: string, id: string): void => { + const key = toEntryKey(entryProjectId, id); + if (killedKeys.has(key)) return; + skippedKeys.add(key); + }; + + const shouldPurgeOpenCode = options?.purgeOpenCode !== false; for (const session of sessions) { try { @@ -939,13 +1337,13 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // Check explicit role metadata first, fall back to naming convention // for pre-existing sessions spawned before the role field was added. if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) { - result.skipped.push(session.id); + pushSkipped(session.projectId, session.id); continue; } const project = config.projects[session.projectId]; if (!project) { - result.skipped.push(session.id); + pushSkipped(session.projectId, session.id); continue; } @@ -986,11 +1384,11 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (shouldKill) { if (!options?.dryRun) { - await kill(session.id); + await kill(session.id, { purgeOpenCode: shouldPurgeOpenCode }); } - result.killed.push(session.id); + pushKilled(session.projectId, session.id); } else { - result.skipped.push(session.id); + pushSkipped(session.projectId, session.id); } } catch (err) { result.errors.push({ @@ -1000,16 +1398,78 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } } + for (const [projectKey, project] of Object.entries(config.projects)) { + if (projectId && projectKey !== projectId) continue; + + const sessionsDir = getProjectSessionsDir(project); + for (const archivedId of listArchivedSessionIds(sessionsDir)) { + if (activeSessionKeys.has(`${projectKey}:${archivedId}`)) continue; + + const archived = readArchivedMetadataRaw(sessionsDir, archivedId); + if (!archived) continue; + + if (archived["role"] === "orchestrator" || archivedId.endsWith("-orchestrator")) { + pushSkipped(projectKey, archivedId); + continue; + } + + const cleanupAgent = archived["agent"] ?? project.agent ?? config.defaults.agent; + const mappedOpenCodeSessionId = asValidOpenCodeSessionId(archived["opencodeSessionId"]); + if (cleanupAgent === "opencode" && archived["opencodeCleanedAt"]) { + pushSkipped(projectKey, archivedId); + continue; + } + if (cleanupAgent === "opencode" && mappedOpenCodeSessionId && shouldPurgeOpenCode) { + if (!options?.dryRun) { + try { + await deleteOpenCodeSession(mappedOpenCodeSessionId); + markArchivedOpenCodeCleanup(sessionsDir, archivedId); + } catch (err) { + result.errors.push({ + sessionId: archivedId, + error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${err instanceof Error ? err.message : String(err)}`, + }); + continue; + } + } + pushKilled(projectKey, archivedId); + } else { + pushSkipped(projectKey, archivedId); + } + } + } + + const allEntryKeys = [...killedKeys, ...skippedKeys]; + const idCounts = new Map(); + for (const entryKey of allEntryKeys) { + const { id } = fromEntryKey(entryKey); + idCounts.set(id, (idCounts.get(id) ?? 0) + 1); + } + + const formatEntry = (entryKey: string): string => { + const { projectId: entryProjectId, id } = fromEntryKey(entryKey); + return (idCounts.get(id) ?? 0) > 1 ? `${entryProjectId}:${id}` : id; + }; + + result.killed = [...killedKeys].map(formatEntry); + result.skipped = [...skippedKeys].map(formatEntry); + return result; } async function send(sessionId: SessionId, message: string): Promise { - const located = findSessionRecord(sessionId); - if (!located) { - throw new Error(`Session ${sessionId} not found`); + const { raw, sessionsDir, project } = requireSessionRecord(sessionId); + const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (discovered) { + raw["opencodeSessionId"] = discovered; + updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered }); + } } - - const { raw, project } = located; const parsedHandle = raw["runtimeHandle"] ? safeJsonParse(raw["runtimeHandle"]) : null; @@ -1084,14 +1544,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return restored; } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, { cause: err }); + throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, { + cause: err, + }); } }; const prepareSession = async (forceRestore = false): Promise => { const current = await get(sessionId); if (!current) { - throw new Error(`Session ${sessionId} not found`); + throw new SessionNotFoundError(sessionId); } const handle = @@ -1105,7 +1567,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (forceRestore || isRestorable(normalized)) { return restoreForDelivery( - forceRestore ? "session needed to be restarted before delivery" : "session is not running", + forceRestore + ? "session needed to be restarted before delivery" + : "session is not running", normalized, ); } @@ -1168,8 +1632,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { await sendWithConfirmation(prepared); } catch (err) { const shouldRetryWithRestore = - prepared.restoredAt === undefined && - !NON_RESTORABLE_STATUSES.has(prepared.status); + prepared.restoredAt === undefined && !NON_RESTORABLE_STATUSES.has(prepared.status); if (!shouldRetryWithRestore) { if (err instanceof Error) { @@ -1198,10 +1661,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const reference = prRef.trim(); if (!reference) throw new Error("PR reference is required"); - const located = findSessionRecord(sessionId); - if (!located) throw new Error(`Session ${sessionId} not found`); - - const { raw, sessionsDir, project, projectId } = located; + const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); if (raw["role"] === "orchestrator") { throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`); } @@ -1294,6 +1754,30 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }; } + async function remap(sessionId: SessionId, force = false): Promise { + const { raw, sessionsDir, project } = requireSessionRecord(sessionId); + + const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + if (selectedAgent !== "opencode") { + throw new Error(`Session ${sessionId} is not using the opencode agent`); + } + + const mapped = asValidOpenCodeSessionId(raw["opencodeSessionId"]); + const discovered = force + ? await discoverOpenCodeSessionIdByTitle(sessionId, OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS) + : (mapped ?? + (await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ))); + if (!discovered) { + throw new Error(`OpenCode session mapping is missing for ${sessionId}`); + } + + updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered }); + return discovered; + } + async function restore(sessionId: SessionId): Promise { // 1. Find session metadata across all projects (active first, then archive) let raw: Record | null = null; @@ -1302,16 +1786,12 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { let projectId: string | undefined; let fromArchive = false; - for (const [key, proj] of Object.entries(config.projects)) { - const dir = getProjectSessionsDir(proj); - const metadata = readMetadataRaw(dir, sessionId); - if (metadata) { - raw = metadata; - sessionsDir = dir; - project = proj; - projectId = key; - break; - } + const activeRecord = findSessionRecord(sessionId); + if (activeRecord) { + raw = activeRecord.raw; + sessionsDir = activeRecord.sessionsDir; + project = activeRecord.project; + projectId = activeRecord.projectId; } // Fall back to archived metadata (killed/cleaned sessions) @@ -1331,26 +1811,22 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } if (!raw || !sessionsDir || !project || !projectId) { - throw new Error(`Session ${sessionId} not found`); + throw new SessionNotFoundError(sessionId); } - // If restored from archive, recreate the active metadata file - if (fromArchive) { - writeMetadata(sessionsDir, sessionId, { - worktree: raw["worktree"] ?? "", - branch: raw["branch"] ?? "", - status: raw["status"] ?? "killed", - role: raw["role"], - tmuxName: raw["tmuxName"], - issue: raw["issue"], - pr: raw["pr"], - prAutoDetect: - raw["prAutoDetect"] === "off" ? "off" : raw["prAutoDetect"] === "on" ? "on" : undefined, - summary: raw["summary"], - project: raw["project"], - createdAt: raw["createdAt"], - runtimeHandle: raw["runtimeHandle"], - }); + const selectedAgent = raw["agent"] ?? project.agent ?? config.defaults.agent; + if (selectedAgent === "opencode" && !asValidOpenCodeSessionId(raw["opencodeSessionId"])) { + const discovered = await discoverOpenCodeSessionIdByTitle( + sessionId, + OPENCODE_INTERACTIVE_DISCOVERY_TIMEOUT_MS, + ); + if (!discovered) { + throw new SessionNotRestorableError(sessionId, "OpenCode session mapping is missing"); + } + raw = { ...raw, opencodeSessionId: discovered }; + if (!fromArchive) { + updateMetadata(sessionsDir, sessionId, { opencodeSessionId: discovered }); + } } // 2. Reconstruct Session from metadata and enrich with live runtime state. @@ -1369,6 +1845,26 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { throw new SessionNotRestorableError(sessionId, "session is not in a terminal state"); } + if (fromArchive) { + writeMetadata(sessionsDir, sessionId, { + worktree: raw["worktree"] ?? "", + branch: raw["branch"] ?? "", + status: raw["status"] ?? "killed", + role: raw["role"], + tmuxName: raw["tmuxName"], + issue: raw["issue"], + pr: raw["pr"], + prAutoDetect: + raw["prAutoDetect"] === "off" ? "off" : raw["prAutoDetect"] === "on" ? "on" : undefined, + summary: raw["summary"], + project: raw["project"], + agent: raw["agent"], + createdAt: raw["createdAt"], + runtimeHandle: raw["runtimeHandle"], + opencodeSessionId: raw["opencodeSessionId"], + }); + } + // 4. Validate required plugins (plugins already resolved above for enrichment) if (!plugins.runtime) { throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); @@ -1425,12 +1921,28 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // 7. Get launch command — try restore command first, fall back to fresh launch let launchCommand: string; + const configuredSubagent = + typeof project.agentConfig?.["subagent"] === "string" + ? project.agentConfig["subagent"] + : undefined; const agentLaunchConfig = { sessionId, - projectConfig: project, + projectConfig: { + ...project, + agentConfig: { + ...(project.agentConfig ?? {}), + ...(session.metadata?.opencodeSessionId + ? { opencodeSessionId: session.metadata.opencodeSessionId } + : {}), + }, + }, issueId: session.issueId ?? undefined, permissions: project.agentConfig?.permissions, - model: project.agentConfig?.model, + model: + raw["role"] === "orchestrator" + ? (project.agentConfig?.orchestratorModel ?? project.agentConfig?.model) + : project.agentConfig?.model, + subagent: configuredSubagent, }; if (plugins.agent.getRestoreCommand) { @@ -1477,7 +1989,19 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (plugins.agent.postLaunchSetup) { try { + const metadataBeforePostLaunch = { ...(restoredSession.metadata ?? {}) }; await plugins.agent.postLaunchSetup(restoredSession); + + const metadataAfterPostLaunch = restoredSession.metadata ?? {}; + const metadataUpdates = Object.fromEntries( + Object.entries(metadataAfterPostLaunch).filter( + ([key, value]) => metadataBeforePostLaunch[key] !== value, + ), + ); + + if (Object.keys(metadataUpdates).length > 0) { + updateMetadata(sessionsDir, sessionId, metadataUpdates); + } } catch { // Non-fatal — session is already running } @@ -1486,5 +2010,5 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return restoredSession; } - return { spawn, spawnOrchestrator, restore, list, get, kill, cleanup, send, claimPR }; + return { spawn, spawnOrchestrator, restore, list, get, kill, cleanup, send, claimPR, remap }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index df21abf20..a2b93c093 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -178,6 +178,8 @@ export interface SessionSpawnConfig { prompt?: string; /** Override the agent plugin for this session (e.g. "codex", "claude-code") */ agent?: string; + /** Override the OpenCode subagent for this session (e.g. "sisyphus", "oracle") */ + subagent?: string; } /** Config for creating an orchestrator session */ @@ -329,7 +331,7 @@ export interface AgentLaunchConfig { projectConfig: ProjectConfig; issueId?: string; prompt?: string; - permissions?: AgentPermissionMode; + permissions?: AgentPermissionInput; model?: string; /** * System prompt to pass to the agent for orchestrator context. @@ -352,6 +354,12 @@ export interface AgentLaunchConfig { * - Codex/Aider: similar shell substitution */ systemPromptFile?: string; + /** + * Specialized OpenCode subagent to use (e.g., sisyphus, oracle, librarian). + * Requires oh-my-opencode to be installed. + * Use --subagent flag to select the subagent. + */ + subagent?: string; } export interface WorkspaceHooksConfig { @@ -903,6 +911,16 @@ export interface ProjectConfig { /** Rules for the orchestrator agent (stored, reserved for future use) */ orchestratorRules?: string; + + orchestratorSessionStrategy?: + | "reuse" + | "delete" + | "ignore" + | "delete-new" + | "ignore-new" + | "kill-previous"; + + opencodeIssueSessionStrategy?: "reuse" | "delete" | "ignore"; } export interface TrackerConfig { @@ -924,9 +942,14 @@ export interface NotifierConfig { export interface AgentSpecificConfig { permissions?: AgentPermissionMode; model?: string; + orchestratorModel?: string; [key: string]: unknown; } +export interface OpenCodeAgentConfig extends AgentSpecificConfig { + opencodeSessionId?: string; +} + /** * Canonical cross-agent permission policy mode. * @@ -952,7 +975,12 @@ export function normalizeAgentPermissionMode( mode: string | undefined, ): AgentPermissionMode | undefined { if (!mode) return undefined; - if (mode !== "permissionless" && mode !== "default" && mode !== "auto-edit" && mode !== "suggest") { + if ( + mode !== "permissionless" && + mode !== "default" && + mode !== "auto-edit" && + mode !== "suggest" + ) { if (mode === "skip") return "permissionless"; return undefined; } @@ -1024,6 +1052,7 @@ export interface SessionMetadata { dashboardPort?: number; terminalWsPort?: number; directTerminalWsPort?: number; + opencodeSessionId?: string; } // ============================================================================= @@ -1037,12 +1066,21 @@ export interface SessionManager { restore(sessionId: SessionId): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; - kill(sessionId: SessionId): Promise; - cleanup(projectId?: string, options?: { dryRun?: boolean }): Promise; + kill(sessionId: SessionId, options?: { purgeOpenCode?: boolean }): Promise; + cleanup( + projectId?: string, + options?: { dryRun?: boolean; purgeOpenCode?: boolean }, + ): Promise; send(sessionId: SessionId, message: string): Promise; claimPR(sessionId: SessionId, prRef: string, options?: ClaimPROptions): Promise; } +/** OpenCode-specific session manager with remap capability */ +export interface OpenCodeSessionManager extends SessionManager { + /** Remap session to OpenCode session ID, returns the mapped OpenCode session ID */ + remap(sessionId: SessionId, force?: boolean): Promise; +} + export interface ClaimPROptions { assignOnGithub?: boolean; takeover?: boolean; @@ -1058,6 +1096,11 @@ export interface ClaimPRResult { takenOverFrom: SessionId[]; } +/** Type guard to check if a SessionManager supports OpenCode-specific remap operation */ +export function isOpenCodeSessionManager(sm: SessionManager): sm is OpenCodeSessionManager { + return typeof (sm as OpenCodeSessionManager).remap === "function"; +} + export interface CleanupResult { killed: string[]; skipped: string[]; @@ -1154,3 +1197,11 @@ export class WorkspaceMissingError extends Error { this.name = "WorkspaceMissingError"; } } + +/** Thrown when a session lookup fails (session does not exist). */ +export class SessionNotFoundError extends Error { + constructor(public readonly sessionId: string) { + super(`Session not found: ${sessionId}`); + this.name = "SessionNotFoundError"; + } +} diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 69e1e4d70..aee969f66 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -28,6 +28,7 @@ "@composio/ao-plugin-notifier-composio": "workspace:*", "@composio/ao-plugin-terminal-iterm2": "workspace:*", "@composio/ao-plugin-terminal-web": "workspace:*", + "@vitest/coverage-v8": "^3.0.0", "@types/node": "^25.2.3", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index 4e61f7e27..2c65a2d45 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -19,7 +19,11 @@ import { mkdtemp, readdir, realpath, rm } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { readLastJsonlEntry, type ActivityDetection, type AgentSessionInfo } from "@composio/ao-core"; +import { + readLastJsonlEntry, + type ActivityDetection, + type AgentSessionInfo, +} from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import claudeCodePlugin, { toClaudeProjectPath } from "@composio/ao-plugin-agent-claude-code"; import { @@ -89,9 +93,7 @@ async function findRealClaudeProject(): Promise<{ continue; } - const jsonlFiles = files.filter( - (f) => f.endsWith(".jsonl") && !f.startsWith("agent-"), - ); + const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); if (jsonlFiles.length === 0) continue; // Try to reconstruct the workspace path from the encoded dir name @@ -163,7 +165,8 @@ describe.skipIf(!realProject)("path encoding & JSONL reading (real Claude data)" // Process is "not running" so should get "exited" — but the important thing // is it didn't return null (which would mean the path didn't resolve) - expect(state).toBe("exited"); + expect(state).not.toBeNull(); + expect(state?.state ?? state).toBe("exited"); }); }); diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index 12f714112..fce7a632b 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -4,14 +4,16 @@ * Requires: * - `opencode` binary on PATH * - tmux installed and running - * - ANTHROPIC_API_KEY or OPENAI_API_KEY set + * - Any supported API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, + * GEMINI_API_KEY, ZAI_API_KEY, ZAI_CODING_PLAN_API_KEY, KIMI_API_KEY, + * OPENROUTER_API_KEY, GROK_API_KEY, GITHUB_TOKEN, COPILOT_API_KEY) * * Skipped automatically when prerequisites are missing. */ import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import type { ActivityDetection, AgentSessionInfo } from "@composio/ao-core"; @@ -48,19 +50,15 @@ async function canOpencodeRun(bin: string): Promise { const probe = "ao-inttest-opencode-probe"; try { await killSessionsByPrefix(probe); - // Run a trivial prompt — opencode run exits after completing await createSession(probe, `${bin} run 'Say hello'`, tmpdir()); - // Wait for the probe to finish (should exit within ~15s) for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 1_000)); try { await execFileAsync("tmux", ["has-session", "-t", probe], { timeout: 5_000 }); } catch { - // session is gone -> opencode exited cleanly return true; } } - // Still running after 20s -> possibly stuck await killSession(probe); return false; } catch { @@ -70,13 +68,39 @@ async function canOpencodeRun(bin: string): Promise { const tmuxOk = await isTmuxAvailable(); const opencodeBin = await findOpencodeBinary(); -const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY); -// Skip the expensive canOpencodeRun probe if no API key is available -const opencodeReady = hasApiKey && opencodeBin !== null && (await canOpencodeRun(opencodeBin)); + +async function hasOpencodeCredentials(): Promise { + const hasEnvKey = Boolean( + process.env.ANTHROPIC_API_KEY || + process.env.OPENAI_API_KEY || + process.env.GOOGLE_API_KEY || + process.env.GEMINI_API_KEY || + process.env.ZAI_API_KEY || + process.env.ZAI_CODING_PLAN_API_KEY || + process.env.KIMI_API_KEY || + process.env.OPENROUTER_API_KEY || + process.env.GROK_API_KEY || + process.env.GITHUB_TOKEN || + process.env.COPILOT_API_KEY, + ); + + if (hasEnvKey) return true; + + try { + const authPath = join(homedir(), ".local", "share", "opencode", "auth.json"); + await readFile(authPath, "utf-8"); + return true; + } catch { + return false; + } +} + +const hasCredentials = await hasOpencodeCredentials(); +const opencodeReady = opencodeBin !== null && hasCredentials && (await canOpencodeRun(opencodeBin)); const canRun = tmuxOk && opencodeReady; // --------------------------------------------------------------------------- -// Tests +// Lifecycle Tests (requires opencode + tmux + API key) // --------------------------------------------------------------------------- describe.skipIf(!canRun)("agent-opencode (integration)", () => { @@ -84,11 +108,8 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const sessionName = `${SESSION_PREFIX}${Date.now()}`; let tmpDir: string; - // Observations captured while the agent is alive let aliveRunning = false; let aliveActivityState: ActivityDetection | null | undefined; - - // Observations captured after the agent exits let exitedRunning: boolean; let exitedActivityState: ActivityDetection | null; let sessionInfo: AgentSessionInfo | null; @@ -103,7 +124,6 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { const handle = makeTmuxHandle(sessionName); const session = makeSession("inttest-opencode", handle, tmpDir); - // Poll until we observe the agent is running and capture activity state const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const running = await agent.isProcessRunning(handle); @@ -118,7 +138,6 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { await sleep(500); } - // Wait for agent to exit exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { timeoutMs: 90_000, intervalMs: 2_000, @@ -135,27 +154,269 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { } }, 30_000); - it("isProcessRunning → true while agent is alive", () => { + it("isProcessRunning -> true while agent is alive", () => { expect(aliveRunning).toBe(true); }); - it("getActivityState → returns null while agent is running (no per-session tracking)", () => { - // OpenCode uses a global SQLite database shared by all sessions, - // so getActivityState honestly returns null instead of guessing. + it("getActivityState -> returns null while agent is running (no per-session tracking)", () => { if (aliveActivityState !== undefined) { expect(aliveActivityState).toBeNull(); } }); - it("isProcessRunning → false after agent exits", () => { + it("isProcessRunning -> false after agent exits", () => { expect(exitedRunning).toBe(false); }); - it("getActivityState → returns exited after agent process terminates", () => { + it("getActivityState -> returns exited after agent process terminates", () => { expect(exitedActivityState?.state).toBe("exited"); }); - it("getSessionInfo → null (not implemented for opencode)", () => { + it("getSessionInfo -> null (not implemented for opencode)", () => { expect(sessionInfo).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Launch command tests (no external dependencies) +// --------------------------------------------------------------------------- + +describe("getLaunchCommand (integration)", () => { + const agent = opencodePlugin.create(); + + const baseConfig = { + sessionId: "test-1", + projectConfig: { + name: "test", + repo: "owner/repo", + path: "/workspace", + defaultBranch: "main", + sessionPrefix: "test", + }, + }; + + it("generates correct command with subagent", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + subagent: "sisyphus", + prompt: "fix the bug", + }); + expect(cmd).toContain("--agent 'sisyphus'"); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).toContain("'fix the bug'"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--agent 'sisyphus'"); + }); + + it("generates correct command with systemPrompt", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + systemPrompt: "You are an orchestrator", + prompt: "do the task", + }); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain("You are an orchestrator"); + expect(cmd).toContain("do the task"); + }); + + it("generates correct command with systemPromptFile", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + systemPromptFile: "/tmp/orchestrator-prompt.md", + prompt: "do the task", + }); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain( + "$(cat '/tmp/orchestrator-prompt.md'; printf '\\n\\n'; printf %s 'do the task')", + ); + }); + + it("generates correct command with model override", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + model: "claude-sonnet-4-5-20250929", + prompt: "do the task", + }); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); + }); + + it("combines subagent + systemPrompt + model + prompt", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + subagent: "oracle", + systemPrompt: "You are an expert", + model: "gpt-5.2", + prompt: "review this code", + }); + expect(cmd).toContain("--agent 'oracle'"); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain("You are an expert"); + expect(cmd).toContain("review this code"); + expect(cmd).toContain("--model 'gpt-5.2'"); + }); + + it("systemPromptFile takes precedence over systemPrompt", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + systemPrompt: "direct prompt", + systemPromptFile: "/tmp/file-prompt.md", + }); + expect(cmd).toContain("\"$(cat '/tmp/file-prompt.md')\""); + expect(cmd).not.toContain("direct prompt"); + }); + + it("uses prompt with systemPromptFile for orchestrator-style launch", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + sessionId: "test-orchestrator", + permissions: "permissionless", + systemPromptFile: "/tmp/orchestrator-prompt.md", + }); + expect(cmd).toContain( + "opencode run --title 'AO:test-orchestrator' \"$(cat '/tmp/orchestrator-prompt.md')\"", + ); + expect(cmd).toContain("&& exec opencode --session"); + }); + + it("escapes single quotes in systemPrompt", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + systemPrompt: "it's important", + }); + expect(cmd).toContain("'it'\\''s important'"); + }); + + it("escapes path with single quotes in systemPromptFile", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + systemPromptFile: "/tmp/it's-prompt.md", + }); + expect(cmd).toContain("\"$(cat '/tmp/it'\\''s-prompt.md')\""); + }); + + it("handles prompt with special shell characters", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + prompt: "fix and `backtick` and 'quote'", + }); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).toContain("fix and `backtick`"); + expect(cmd).not.toContain("--prompt"); + }); + + it("handles empty prompt", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + prompt: "", + }); + expect(cmd).toContain("opencode run --title 'AO:test-1' --command true"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("opencode session list --format json"); + expect(cmd).toContain("AO:test-1"); + }); + + it("handles prompt with newlines", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + prompt: "line1\nline2", + }); + expect(cmd).toContain("opencode run --title 'AO:test-1'"); + expect(cmd).toContain("'line1"); + expect(cmd).not.toContain("--prompt"); + }); + + it("uses run bootstrap launch for fresh sessions", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + prompt: "start work", + }); + expect(cmd).toContain("--title 'AO:test-1'"); + expect(cmd).not.toContain("--prompt 'start work'"); + expect(cmd).toContain("'start work' && exec opencode --session"); + }); + + it("uses --session when existing OpenCode session id is provided", () => { + const cmd = agent.getLaunchCommand({ + ...baseConfig, + projectConfig: { + ...baseConfig.projectConfig, + agentConfig: { + opencodeSessionId: "ses_abc123", + }, + }, + prompt: "continue", + }); + expect(cmd).toBe("opencode --session 'ses_abc123' --prompt 'continue'"); + }); +}); + +// --------------------------------------------------------------------------- +// getEnvironment tests +// --------------------------------------------------------------------------- + +describe("getEnvironment (integration)", () => { + const agent = opencodePlugin.create(); + + const baseConfig = { + sessionId: "sess-123", + projectConfig: { + name: "test", + repo: "owner/repo", + path: "/workspace", + defaultBranch: "main", + sessionPrefix: "test", + }, + }; + + it("sets AO_SESSION_ID", () => { + const env = agent.getEnvironment(baseConfig); + expect(env["AO_SESSION_ID"]).toBe("sess-123"); + }); + + it("sets AO_ISSUE_ID when provided", () => { + const env = agent.getEnvironment({ ...baseConfig, issueId: "GH-42" }); + expect(env["AO_ISSUE_ID"]).toBe("GH-42"); + }); + + it("omits AO_ISSUE_ID when not provided", () => { + const env = agent.getEnvironment(baseConfig); + expect(env["AO_ISSUE_ID"]).toBeUndefined(); + }); + + it("does not set AO_PROJECT_ID (caller's responsibility)", () => { + const env = agent.getEnvironment(baseConfig); + expect(env["AO_PROJECT_ID"]).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// detectActivity tests +// --------------------------------------------------------------------------- + +describe("detectActivity (integration)", () => { + const agent = opencodePlugin.create(); + + it("returns idle for empty terminal output", () => { + expect(agent.detectActivity("")).toBe("idle"); + }); + + it("returns idle for whitespace-only terminal output", () => { + expect(agent.detectActivity(" \n ")).toBe("idle"); + }); + + it("returns active for non-empty terminal output", () => { + expect(agent.detectActivity("opencode is working\n")).toBe("active"); + }); + + it("returns active for output with ANSI codes", () => { + expect(agent.detectActivity("\u001b[32mSuccess\u001b[0m\n")).toBe("active"); + }); + + it("returns active for multiline output", () => { + expect(agent.detectActivity("line1\nline2\nline3\n")).toBe("active"); + }); +}); diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index ff7d21133..c29a6539e 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -48,50 +48,72 @@ function linearGraphQL(query: string, variables: Record): Pr } const body = JSON.stringify({ query, variables }); - return new Promise((resolve, reject) => { - const url = new URL("https://api.linear.app/graphql"); - const req = request( - { - hostname: url.hostname, - path: url.pathname, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: LINEAR_API_KEY, - "Content-Length": Buffer.byteLength(body), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - try { - const text = Buffer.concat(chunks).toString("utf-8"); - const json = JSON.parse(text) as { - data?: T; - errors?: Array<{ message: string }>; - }; - if (json.errors?.length) { - reject(new Error(`Linear API error: ${json.errors[0].message}`)); - return; - } - resolve(json.data as T); - } catch (err) { - reject(err); - } + async function executeWithRetry(attempt = 1): Promise { + try { + return await new Promise((resolve, reject) => { + const url = new URL("https://api.linear.app/graphql"); + const req = request( + { + hostname: url.hostname, + path: url.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: LINEAR_API_KEY, + "Content-Length": Buffer.byteLength(body), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf-8"); + const statusCode = res.statusCode ?? 0; + + if (statusCode >= 500) { + reject(new Error(`Linear API ${statusCode}: ${text.slice(0, 200)}`)); + return; + } + + let json: { data?: T; errors?: Array<{ message: string }> }; + try { + json = JSON.parse(text) as { data?: T; errors?: Array<{ message: string }> }; + } catch { + reject( + new Error(`Linear API returned non-JSON (${statusCode}): ${text.slice(0, 200)}`), + ); + return; + } + + if (json.errors?.length) { + reject(new Error(`Linear API error: ${json.errors[0].message}`)); + return; + } + + resolve(json.data as T); + }); + }, + ); + + req.setTimeout(30_000, () => { + req.destroy(); + reject(new Error("Linear API request timed out")); }); - }, - ); - req.setTimeout(30_000, () => { - req.destroy(); - reject(new Error("Linear API request timed out")); - }); + req.on("error", (err) => reject(err)); + req.write(body); + req.end(); + }); + } catch (err) { + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + return executeWithRetry(attempt + 1); + } + throw err; + } + } - req.on("error", (err) => reject(err)); - req.write(body); - req.end(); - }); + return executeWithRetry(); } // --------------------------------------------------------------------------- @@ -135,11 +157,15 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // Resolve the UUID for cleanup — only possible with direct API key if (LINEAR_API_KEY) { - const data = await linearGraphQL<{ issue: { id: string } }>( - `query($id: String!) { issue(id: $id) { id } }`, - { id: issueIdentifier }, - ); - issueUuid = data.issue.id; + try { + const data = await linearGraphQL<{ issue: { id: string } }>( + `query($id: String!) { issue(id: $id) { id } }`, + { id: issueIdentifier }, + ); + issueUuid = data.issue.id; + } catch { + issueUuid = undefined; + } } }, 30_000); @@ -238,18 +264,25 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // Verify the comment was added — use direct API if available, // otherwise trust the plugin didn't throw if (LINEAR_API_KEY) { - const data = await linearGraphQL<{ - issue: { comments: { nodes: Array<{ body: string }> } }; - }>( - `query($id: String!) { - issue(id: $id) { - comments { nodes { body } } - } - }`, - { id: issueIdentifier }, + const commentBodies = await pollUntil( + async () => { + const data = await linearGraphQL<{ + issue: { comments: { nodes: Array<{ body: string }> } }; + }>( + `query($id: String!) { + issue(id: $id) { + comments(first: 50) { nodes { body } } + } + }`, + { id: issueIdentifier }, + ); + + const bodies = data.issue.comments.nodes.map((c) => c.body); + return bodies.includes("Integration test comment") ? bodies : undefined; + }, + { timeoutMs: 5_000, intervalMs: 500 }, ); - const commentBodies = data.issue.comments.nodes.map((c) => c.body); expect(commentBodies).toContain("Integration test comment"); } }); @@ -265,8 +298,12 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { ); expect(completed).toBe(true); - const issue = await tracker.getIssue(issueIdentifier, project); - expect(issue.state).toBe("closed"); + const closedState = await pollUntilEqual( + async () => (await tracker.getIssue(issueIdentifier, project)).state, + "closed", + { timeoutMs: 5_000, intervalMs: 500 }, + ); + expect(closedState).toBe("closed"); }); it("updateIssue reopens the issue", async () => { @@ -280,7 +317,11 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { ); expect(completed).toBe(false); - const issue = await tracker.getIssue(issueIdentifier, project); - expect(issue.state).toBe("open"); + const reopenedState = await pollUntilEqual( + async () => (await tracker.getIssue(issueIdentifier, project)).state, + "open", + { timeoutMs: 5_000, intervalMs: 500 }, + ); + expect(reopenedState).toBe("open"); }); }); diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index fa24045f9..9a6d2dead 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -111,12 +111,18 @@ describe("getLaunchCommand", () => { const agent = create(); it("generates base command without prompt", () => { - expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("opencode"); + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("opencode session list --format json"); + expect(cmd).toContain("AO:sess-1"); + expect(cmd).toContain("try { rows = JSON.parse(input); } catch { process.exit(1); }"); }); - it("uses run subcommand with shell-escaped prompt", () => { + it("uses --prompt with shell-escaped prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" })); - expect(cmd).toContain("run 'Fix it'"); + expect(cmd).toContain("opencode run --title 'AO:sess-1' 'Fix it'"); + expect(cmd).toContain("&& exec opencode --session"); }); it("includes --model with shell-escaped value", () => { @@ -125,19 +131,249 @@ describe("getLaunchCommand", () => { }); it("combines prompt and model", () => { - const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Go", model: "gpt-4o" })); - expect(cmd).toBe("opencode run 'Go' --model 'gpt-4o'"); + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }), + ); + expect(cmd).toContain( + "opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'", + ); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); }); it("escapes single quotes in prompt (POSIX shell escaping)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" })); - expect(cmd).toContain("run 'it'\\''s broken'"); + expect(cmd).toContain("opencode run --title 'AO:sess-1' 'it'\\''s broken'"); + expect(cmd).toContain("&& exec opencode --session"); }); it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); expect(cmd).not.toContain("--model"); - expect(cmd).not.toContain("run"); + expect(cmd).not.toContain("--agent"); + }); + + // --------------------------------------------------------------------------- + // subagent flag tests + // --------------------------------------------------------------------------- + it("includes --agent flag when subagent is provided", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ subagent: "sisyphus" })); + expect(cmd).toContain("--agent 'sisyphus'"); + }); + + it("generates command with agent and prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ subagent: "sisyphus", prompt: "fix bug" }), + ); + expect(cmd).toContain("opencode run --title 'AO:sess-1' --agent 'sisyphus' 'fix bug'"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--agent 'sisyphus'"); + }); + + it("generates command with agent, model, and prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + subagent: "sisyphus", + model: "claude-sonnet-4-5-20250929", + prompt: "fix the bug", + }), + ); + expect(cmd).toContain( + "opencode run --title 'AO:sess-1' --agent 'sisyphus' --model 'claude-sonnet-4-5-20250929' 'fix the bug'", + ); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--agent 'sisyphus'"); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); + }); + + it("works with different agent names: oracle", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ subagent: "oracle", prompt: "review code" }), + ); + expect(cmd).toContain("--agent 'oracle'"); + expect(cmd).toContain("'review code'"); + }); + + it("works with different agent names: librarian", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ subagent: "librarian", prompt: "find usages" }), + ); + expect(cmd).toContain("--agent 'librarian'"); + expect(cmd).toContain("'find usages'"); + }); + + it("backward compatible: no agent flag when subagent not provided", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix it" })); + expect(cmd).not.toContain("--agent"); + expect(cmd).toContain("opencode run --title 'AO:sess-1' 'fix it'"); + expect(cmd).toContain("&& exec opencode --session"); + }); + + it("combines model and prompt without agent (backward compatible)", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ prompt: "Go", model: "claude-sonnet-4-5-20250929" }), + ); + expect(cmd).not.toContain("--agent"); + expect(cmd).toContain( + "opencode run --title 'AO:sess-1' --model 'claude-sonnet-4-5-20250929' 'Go'", + ); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'"); + }); + + // --------------------------------------------------------------------------- + // systemPrompt tests + // --------------------------------------------------------------------------- + it("uses run bootstrap when systemPrompt is provided", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPrompt: "You are an orchestrator" }), + ); + expect(cmd).toContain("opencode run --title 'AO:sess-1'"); + expect(cmd).toContain("'You are an orchestrator'"); + }); + + it("generates command with systemPrompt and task prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPrompt: "You are an orchestrator", prompt: "do the task" }), + ); + expect(cmd).toContain("opencode run --title 'AO:sess-1'"); + expect(cmd).not.toContain("--prompt 'You are an orchestrator"); + expect(cmd).toContain("do the task'"); + }); + + it("escapes single quotes in systemPrompt", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "it's important" })); + expect(cmd).toContain("'it'\\''s important'"); + }); + + it("handles very long systemPrompt", () => { + const longPrompt = "A".repeat(500); + const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: longPrompt })); + expect(cmd).toContain("opencode run --title 'AO:sess-1'"); + expect(cmd.length).toBeGreaterThan(500); + }); + + it("generates command with systemPromptFile via shell substitution", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" })); + expect(cmd).toContain("opencode run --title 'AO:sess-1' \"$(cat '/tmp/prompt.md')\""); + expect(cmd).toContain("&& exec opencode --session"); + }); + + it("escapes path in systemPromptFile", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ systemPromptFile: "/tmp/it's-prompt.md" }), + ); + expect(cmd).toContain("opencode run --title 'AO:sess-1' \"$(cat '/tmp/it'\\''s-prompt.md')\""); + expect(cmd).toContain("&& exec opencode --session"); + }); + + it("systemPromptFile takes precedence over systemPrompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + systemPrompt: "direct prompt", + systemPromptFile: "/tmp/file-prompt.md", + }), + ); + expect(cmd).toContain("\"$(cat '/tmp/file-prompt.md')\""); + expect(cmd).not.toContain("direct prompt"); + }); + + it("generates orchestrator-style systemPromptFile launch", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + sessionId: "my-orchestrator", + permissions: "permissionless", + systemPromptFile: "/tmp/orchestrator.md", + }), + ); + expect(cmd).toContain( + "opencode run --title 'AO:my-orchestrator' \"$(cat '/tmp/orchestrator.md')\"", + ); + expect(cmd).toContain("&& exec opencode --session"); + }); + + it("combines systemPromptFile with subagent and prompt", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + systemPromptFile: "/tmp/orchestrator.md", + subagent: "sisyphus", + prompt: "fix the bug", + }), + ); + expect(cmd).toContain("--agent 'sisyphus'"); + expect(cmd).toContain("opencode run --title 'AO:sess-1'"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("--agent 'sisyphus'"); + expect(cmd).not.toContain("--prompt"); + expect(cmd).toContain( + "$(cat '/tmp/orchestrator.md'; printf '\\n\\n'; printf %s 'fix the bug')", + ); + }); + + // --------------------------------------------------------------------------- + // edge cases + // --------------------------------------------------------------------------- + it("handles prompt with special characters", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ prompt: "fix $PATH/to/file and `rm -rf /unquoted/path`" }), + ); + expect(cmd).toContain("'fix $PATH/to/file and `rm -rf /unquoted/path`"); + }); + + it("handles prompt with newlines", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1\nline2\nline3" })); + expect(cmd).toContain("opencode run --title 'AO:sess-1'"); + expect(cmd).toContain("'line1"); + }); + + it("handles prompt with backticks", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "use `backticks` and $vars`" })); + expect(cmd).toContain("'use `backticks` and $vars`"); + }); + + it("handles prompt with dollar signs", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "cost is $100" })); + expect(cmd).toContain("'cost is $100'"); + }); + + it("handles prompt with double quotes", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: 'say "hello" and "goodbye"' })); + expect(cmd).toContain('\'say "hello" and "goodbye"\''); + }); + + it("handles prompt with unicode characters", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "fix bug in café.js file" })); + expect(cmd).toContain("'fix bug in café.js file'"); + }); + + it("handles prompt with semicolons", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "line1; line2; line3" })); + expect(cmd).toContain("'line1; line2; line3'"); + }); + + it("handles empty prompt", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "" })); + expect(cmd).toContain("opencode run --title 'AO:sess-1' --command true"); + expect(cmd).toContain("&& exec opencode --session"); + expect(cmd).toContain("opencode session list --format json"); + expect(cmd).toContain("AO:sess-1"); + }); + + it("uses existing session id", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + projectConfig: { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { opencodeSessionId: "ses_abc123" }, + }, + prompt: "continue", + }), + ); + expect(cmd).toBe("opencode --session 'ses_abc123' --prompt 'continue'"); }); }); @@ -250,6 +486,33 @@ describe("detectActivity", () => { }); }); +describe("getActivityState", () => { + const agent = create(); + + it("returns null when opencode session list output is malformed JSON", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") { + return Promise.resolve({ + stdout: " PID TT ARGS\n 789 ttys003 opencode\n", + stderr: "", + }); + } + if (cmd === "opencode") return Promise.resolve({ stdout: "not json", stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + + const state = await agent.getActivityState( + makeSession({ + runtimeHandle: makeTmuxHandle(), + metadata: { opencodeSessionId: "ses_abc123" }, + }), + ); + + expect(state).toBeNull(); + }); +}); + // ========================================================================= // getSessionInfo // ========================================================================= diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 5b4f2ffd9..7a21dc58e 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -1,5 +1,6 @@ import { shellEscape, + asValidOpenCodeSessionId, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -8,12 +9,55 @@ import { type PluginModule, type RuntimeHandle, type Session, + type OpenCodeAgentConfig, } from "@composio/ao-core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +interface OpenCodeSessionListEntry { + id: string; + title?: string; + updated?: string; +} + +function parseSessionList(raw: string): OpenCodeSessionListEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is OpenCodeSessionListEntry => { + if (!item || typeof item !== "object") return false; + const record = item as Record; + return asValidOpenCodeSessionId(record["id"]) !== undefined; + }); +} + +function buildSessionLookupScript(): string { + const script = ` +let input = ''; +process.stdin.on('data', c => input += c).on('end', () => { + const title = process.argv[1]; + let rows; + try { rows = JSON.parse(input); } catch { process.exit(1); } + if (!Array.isArray(rows)) process.exit(1); + const matches = rows.filter(r => r && r.title === title && typeof r.id === 'string'); + if (matches.length === 0) process.exit(1); + process.stdout.write(matches[0].id); +}); + `.trim(); + return script.replace(/\n/g, " ").replace(/\s+/g, " "); +} + +function buildContinueSessionCommand(sessionTitle: string): string { + const script = buildSessionLookupScript(); + return `"$(opencode session list --format json | node -e ${shellEscape(script)} ${shellEscape(sessionTitle)})"`; +} + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -35,17 +79,60 @@ function createOpenCodeAgent(): Agent { processName: "opencode", getLaunchCommand(config: AgentLaunchConfig): string { - const parts: string[] = ["opencode"]; + const options: string[] = []; + const sharedOptions: string[] = []; + const existingSessionId = asValidOpenCodeSessionId( + (config.projectConfig.agentConfig as OpenCodeAgentConfig | undefined)?.opencodeSessionId, + ); + + if (existingSessionId) { + options.push("--session", shellEscape(existingSessionId)); + } + + // Select specific OpenCode subagent if configured + if (config.subagent) { + sharedOptions.push("--agent", shellEscape(config.subagent)); + } + + let promptValue: string | undefined; if (config.prompt) { - parts.push("run", shellEscape(config.prompt)); + if (config.systemPromptFile) { + promptValue = `"$(cat ${shellEscape(config.systemPromptFile)}; printf '\\n\\n'; printf %s ${shellEscape(config.prompt)})"`; + } else if (config.systemPrompt) { + promptValue = shellEscape(`${config.systemPrompt}\n\n${config.prompt}`); + } else { + promptValue = shellEscape(config.prompt); + } + } else if (config.systemPromptFile) { + promptValue = `"$(cat ${shellEscape(config.systemPromptFile)})"`; + } else if (config.systemPrompt) { + promptValue = shellEscape(config.systemPrompt); } if (config.model) { - parts.push("--model", shellEscape(config.model)); + sharedOptions.push("--model", shellEscape(config.model)); } - return parts.join(" "); + if (!existingSessionId) { + const runOptions = ["--title", shellEscape(`AO:${config.sessionId}`), ...sharedOptions]; + const runCommand = promptValue + ? ["opencode", "run", ...runOptions, promptValue].join(" ") + : ["opencode", "run", ...runOptions, "--command", "true"].join(" "); + const continueSession = buildContinueSessionCommand(`AO:${config.sessionId}`); + const continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join( + " ", + ); + return `${runCommand} && exec ${continueCommand}`; + } + + if (promptValue) { + options.push("--prompt", promptValue); + } + + options.push(...sharedOptions); + + return ["opencode", ...options].join(" "); }, getEnvironment(config: AgentLaunchConfig): Record { @@ -64,20 +151,42 @@ function createOpenCodeAgent(): Agent { return "active"; }, - async getActivityState(session: Session, _readyThresholdMs?: number): Promise { + async getActivityState( + session: Session, + _readyThresholdMs?: number, + ): Promise { // Check if process is running first const exitedAt = new Date(); if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; const running = await this.isProcessRunning(session.runtimeHandle); if (!running) return { state: "exited", timestamp: exitedAt }; - // NOTE: OpenCode stores all session data in a single global SQLite database - // at ~/.local/share/opencode/opencode.db without per-workspace scoping. When - // multiple OpenCode sessions run in parallel, database modifications from any - // session will cause all sessions to appear active. Until OpenCode provides - // per-workspace session tracking, we return null (unknown) rather than guessing. - // - // TODO: Implement proper per-session activity detection when OpenCode supports it. + if (session.metadata?.opencodeSessionId) { + try { + const { stdout } = await execFileAsync( + "opencode", + ["session", "list", "--format", "json"], + { timeout: 30_000 }, + ); + + const sessions = parseSessionList(stdout); + const targetSession = sessions.find((s) => s.id === session.metadata.opencodeSessionId); + + if (targetSession) { + const lastActivity = targetSession.updated + ? new Date(targetSession.updated) + : undefined; + return { + state: "active", + ...(lastActivity && + !Number.isNaN(lastActivity.getTime()) && { timestamp: lastActivity }), + }; + } + } catch { + return null; + } + } + return null; }, diff --git a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts index 3f42baa7e..2f8eecbf4 100644 --- a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts +++ b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts @@ -27,6 +27,7 @@ vi.mock("node:fs", () => ({ const mockExecFileCustom = (childProcess.execFile as any)[ Symbol.for("nodejs.util.promisify.custom") ] as ReturnType; +const expectedTmuxOptions = { timeout: 5_000 }; /** Queue a successful tmux command with the given stdout. */ function mockTmuxSuccess(stdout = "") { @@ -98,14 +99,11 @@ describe("runtime.create()", () => { expect(handle.data.workspacePath).toBe("/tmp/workspace"); // First call: new-session - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "new-session", - "-d", - "-s", - "test-session", - "-c", - "/tmp/workspace", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["new-session", "-d", "-s", "test-session", "-c", "/tmp/workspace"], + expectedTmuxOptions, + ); }); it("includes -e KEY=VALUE flags for environment variables", async () => { @@ -143,13 +141,11 @@ describe("runtime.create()", () => { }); // Second call: send-keys with the launch command - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "send-keys", - "-t", - "launch-test", - "claude --session abc", - "Enter", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["send-keys", "-t", "launch-test", "claude --session abc", "Enter"], + expectedTmuxOptions, + ); }); it("cleans up session if send-keys fails", async () => { @@ -172,7 +168,11 @@ describe("runtime.create()", () => { ).rejects.toThrow('Failed to send launch command to session "fail-session"'); // Verify kill-session was called for cleanup - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "fail-session"]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["kill-session", "-t", "fail-session"], + expectedTmuxOptions, + ); }); it("rejects invalid session IDs with special characters", async () => { @@ -244,7 +244,11 @@ describe("runtime.destroy()", () => { await runtime.destroy(handle); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "destroy-test"]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["kill-session", "-t", "destroy-test"], + expectedTmuxOptions, + ); }); it("does not throw if session is already gone", async () => { @@ -273,29 +277,28 @@ describe("runtime.sendMessage()", () => { expect(mockExecFileCustom).toHaveBeenCalledTimes(3); // Call 0: Clear partial input - expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ - "send-keys", - "-t", - "msg-short", - "C-u", - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 1, + "tmux", + ["send-keys", "-t", "msg-short", "C-u"], + expectedTmuxOptions, + ); // Call 1: Literal text - expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ - "send-keys", - "-t", - "msg-short", - "-l", - "hello world", - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 2, + "tmux", + ["send-keys", "-t", "msg-short", "-l", "hello world"], + expectedTmuxOptions, + ); // Call 2: Enter - expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ - "send-keys", - "-t", - "msg-short", - "Enter", - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 3, + "tmux", + ["send-keys", "-t", "msg-short", "Enter"], + expectedTmuxOptions, + ); }); it("uses load-buffer + paste-buffer for long text (> 200 chars)", async () => { @@ -315,30 +318,33 @@ describe("runtime.sendMessage()", () => { expect(mockExecFileCustom).toHaveBeenCalledTimes(5); // Call 0: clear - expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ - "send-keys", - "-t", - "msg-long", - "C-u", - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 1, + "tmux", + ["send-keys", "-t", "msg-long", "C-u"], + expectedTmuxOptions, + ); // Call 1: load-buffer with named buffer - expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ - "load-buffer", - "-b", - "ao-test-uuid-1234", - expect.stringContaining("ao-send-test-uuid-1234.txt"), - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 2, + "tmux", + [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ], + expectedTmuxOptions, + ); // Call 2: paste-buffer - expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ - "paste-buffer", - "-b", - "ao-test-uuid-1234", - "-t", - "msg-long", - "-d", - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 3, + "tmux", + ["paste-buffer", "-b", "ao-test-uuid-1234", "-t", "msg-long", "-d"], + expectedTmuxOptions, + ); // Verify writeFileSync was called with the message expect(fs.writeFileSync).toHaveBeenCalledWith( @@ -366,12 +372,17 @@ describe("runtime.sendMessage()", () => { await runtime.sendMessage(handle, "line1\nline2\nline3"); // Should use buffer path, not send-keys -l - expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ - "load-buffer", - "-b", - "ao-test-uuid-1234", - expect.stringContaining("ao-send-test-uuid-1234.txt"), - ]); + expect(mockExecFileCustom).toHaveBeenNthCalledWith( + 2, + "tmux", + [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ], + expectedTmuxOptions, + ); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining("ao-send-test-uuid-1234.txt"), @@ -401,11 +412,11 @@ describe("runtime.sendMessage()", () => { ); // delete-buffer should be called in finally block - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "delete-buffer", - "-b", - "ao-test-uuid-1234", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["delete-buffer", "-b", "ao-test-uuid-1234"], + expectedTmuxOptions, + ); }); }); @@ -419,14 +430,11 @@ describe("runtime.getOutput()", () => { const output = await runtime.getOutput(handle); expect(output).toBe("some output\nfrom tmux"); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "capture-pane", - "-t", - "output-test", - "-p", - "-S", - "-50", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["capture-pane", "-t", "output-test", "-p", "-S", "-50"], + expectedTmuxOptions, + ); }); it("passes custom line count", async () => { @@ -437,14 +445,11 @@ describe("runtime.getOutput()", () => { await runtime.getOutput(handle, 100); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "capture-pane", - "-t", - "output-custom", - "-p", - "-S", - "-100", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["capture-pane", "-t", "output-custom", "-p", "-S", "-100"], + expectedTmuxOptions, + ); }); it("returns empty string on error", async () => { @@ -469,7 +474,11 @@ describe("runtime.isAlive()", () => { const alive = await runtime.isAlive(handle); expect(alive).toBe(true); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["has-session", "-t", "alive-test"]); + expect(mockExecFileCustom).toHaveBeenCalledWith( + "tmux", + ["has-session", "-t", "alive-test"], + expectedTmuxOptions, + ); }); it("returns false when has-session fails", async () => { diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index 8475289d9..a6be813d3 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -15,6 +15,7 @@ import type { } from "@composio/ao-core"; const execFileAsync = promisify(execFile); +const TMUX_COMMAND_TIMEOUT_MS = 5_000; export const manifest = { name: "tmux", @@ -34,7 +35,9 @@ function assertValidSessionId(id: string): void { /** Run a tmux command and return stdout */ async function tmux(...args: string[]): Promise { - const { stdout } = await execFileAsync("tmux", args); + const { stdout } = await execFileAsync("tmux", args, { + timeout: TMUX_COMMAND_TIMEOUT_MS, + }); return stdout.trimEnd(); } diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 0b481ba4e..6222943ac 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -87,6 +87,111 @@ function parseDate(val: string | undefined | null): Date { return isNaN(d.getTime()) ? new Date(0) : d; } +function isUnsupportedPrChecksJsonError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return /pr checks/i.test(err.message) && /unknown json field/i.test(err.message); +} + +function mapRawCheckStateToStatus(rawState: string | undefined): CICheck["status"] { + const state = (rawState ?? "").toUpperCase(); + if (state === "IN_PROGRESS") return "running"; + if ( + state === "PENDING" || + state === "QUEUED" || + state === "REQUESTED" || + state === "WAITING" || + state === "EXPECTED" + ) { + return "pending"; + } + if (state === "SUCCESS") return "passed"; + if ( + state === "FAILURE" || + state === "TIMED_OUT" || + state === "CANCELLED" || + state === "ACTION_REQUIRED" || + state === "ERROR" + ) { + return "failed"; + } + if ( + state === "SKIPPED" || + state === "NEUTRAL" || + state === "STALE" || + state === "NOT_REQUIRED" || + state === "NONE" || + state === "" + ) { + return "skipped"; + } + + return "skipped"; +} + +async function getCIChecksFromStatusRollup(pr: PRInfo): Promise { + const raw = await gh([ + "pr", + "view", + String(pr.number), + "--repo", + repoFlag(pr), + "--json", + "statusCheckRollup", + ]); + + const data: { statusCheckRollup?: unknown[] } = JSON.parse(raw); + const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : []; + + return rollup + .map((entry): CICheck | null => { + if (!entry || typeof entry !== "object") return null; + const row = entry as Record; + const name = + (typeof row["name"] === "string" && row["name"]) || + (typeof row["context"] === "string" && row["context"]); + if (!name) return null; + + const rawState = + typeof row["conclusion"] === "string" + ? row["conclusion"] + : typeof row["state"] === "string" + ? row["state"] + : typeof row["status"] === "string" + ? row["status"] + : undefined; + + const url = + (typeof row["link"] === "string" && row["link"]) || + (typeof row["detailsUrl"] === "string" && row["detailsUrl"]) || + (typeof row["targetUrl"] === "string" && row["targetUrl"]) || + undefined; + + const startedAtRaw = + typeof row["startedAt"] === "string" + ? row["startedAt"] + : typeof row["createdAt"] === "string" + ? row["createdAt"] + : undefined; + const completedAtRaw = + typeof row["completedAt"] === "string" ? row["completedAt"] : undefined; + + const check: CICheck = { + name, + status: mapRawCheckStateToStatus(rawState), + conclusion: typeof rawState === "string" ? rawState.toUpperCase() : undefined, + startedAt: startedAtRaw ? new Date(startedAtRaw) : undefined, + completedAt: completedAtRaw ? new Date(completedAtRaw) : undefined, + }; + + if (url) { + check.url = url; + } + + return check; + }) + .filter((check): check is CICheck => check !== null); +} + function parseProjectRepo(projectRepo: string): [string, string] { const parts = projectRepo.split("/"); if (parts.length !== 2 || !parts[0] || !parts[1]) { @@ -279,29 +384,8 @@ function createGitHubSCM(): SCM { }> = JSON.parse(raw); return checks.map((c) => { - let status: CICheck["status"]; const state = c.state?.toUpperCase(); - - // gh pr checks returns state directly: SUCCESS, FAILURE, PENDING, QUEUED, etc. - if (state === "PENDING" || state === "QUEUED") { - status = "pending"; - } else if (state === "IN_PROGRESS") { - status = "running"; - } else if (state === "SUCCESS") { - status = "passed"; - } else if ( - state === "FAILURE" || - state === "TIMED_OUT" || - state === "CANCELLED" || - state === "ACTION_REQUIRED" - ) { - status = "failed"; - } else if (state === "SKIPPED" || state === "NEUTRAL") { - status = "skipped"; - } else { - // Unknown state on a check — fail closed for safety - status = "failed"; - } + const status = mapRawCheckStateToStatus(state); return { name: c.name, @@ -313,6 +397,9 @@ function createGitHubSCM(): SCM { }; }); } catch (err) { + if (isUnsupportedPrChecksJsonError(err)) { + return getCIChecksFromStatusRollup(pr); + } // Propagate so callers (getCISummary) can decide how to handle. // Do NOT silently return [] — that causes a fail-open where CI // appears healthy when we simply failed to fetch check status. diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 0aec3b924..6b2523d54 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -337,10 +337,12 @@ describe("scm-github plugin", () => { { name: "queued", state: "QUEUED", link: "", startedAt: "", completedAt: "" }, { name: "cancelled", state: "CANCELLED", link: "", startedAt: "", completedAt: "" }, { name: "action_req", state: "ACTION_REQUIRED", link: "", startedAt: "", completedAt: "" }, + { name: "stale", state: "STALE", link: "", startedAt: "", completedAt: "" }, + { name: "unknown", state: "SOME_NEW_STATE", link: "", startedAt: "", completedAt: "" }, ]); const checks = await scm.getCIChecks(pr); - expect(checks).toHaveLength(10); + expect(checks).toHaveLength(12); expect(checks[0].status).toBe("passed"); expect(checks[0].url).toBe("https://ci/1"); expect(checks[1].status).toBe("failed"); @@ -352,6 +354,8 @@ describe("scm-github plugin", () => { expect(checks[7].status).toBe("pending"); expect(checks[8].status).toBe("failed"); // CANCELLED expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED + expect(checks[10].status).toBe("skipped"); + expect(checks[11].status).toBe("skipped"); }); it("throws on error (fail-closed)", async () => { @@ -371,6 +375,36 @@ describe("scm-github plugin", () => { expect(checks[0].startedAt).toBeUndefined(); expect(checks[0].completedAt).toBeUndefined(); }); + + it("falls back to statusCheckRollup when gh pr checks --json is unsupported", async () => { + mockGhError('gh pr checks failed: Unknown JSON field "state"'); + mockGh({ + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "Test", + status: "COMPLETED", + conclusion: "SUCCESS", + detailsUrl: "https://ci/test", + startedAt: "2025-01-01T00:00:00Z", + completedAt: "2025-01-01T00:05:00Z", + }, + { + __typename: "StatusContext", + context: "Lint", + state: "PENDING", + targetUrl: "https://ci/lint", + createdAt: "2025-01-01T00:01:00Z", + }, + ], + }); + + const checks = await scm.getCIChecks(pr); + expect(checks).toHaveLength(2); + expect(checks[0]).toMatchObject({ name: "Test", status: "passed", url: "https://ci/test" }); + expect(checks[1]).toMatchObject({ name: "Lint", status: "pending", url: "https://ci/lint" }); + expect(ghMock).toHaveBeenCalledTimes(2); + }); }); // ---- getCISummary ------------------------------------------------------ @@ -417,6 +451,32 @@ describe("scm-github plugin", () => { ]); expect(await scm.getCISummary(pr)).toBe("none"); }); + + it('returns "none" for stale/unknown check states instead of false failing', async () => { + mockGh([ + { name: "a", state: "STALE" }, + { name: "b", state: "SOME_NEW_STATE" }, + ]); + expect(await scm.getCISummary(pr)).toBe("none"); + }); + + it('uses fallback checks source before reporting "failing"', async () => { + mockGhError('gh pr checks failed: Unknown JSON field "state"'); + mockGh({ + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "Build", + status: "COMPLETED", + conclusion: "SUCCESS", + detailsUrl: "https://ci/build", + }, + ], + }); + + expect(await scm.getCISummary(pr)).toBe("passing"); + expect(ghMock).toHaveBeenCalledTimes(2); + }); }); // ---- getReviews -------------------------------------------------------- diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index f5b007e68..7f3afef26 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -36,6 +36,70 @@ async function gh(args: string[]): Promise { } } +function getErrorText(err: unknown): string { + if (!(err instanceof Error)) return ""; + + const details: string[] = [err.message]; + const withIo = err as Error & { stderr?: string; stdout?: string; cause?: unknown }; + if (typeof withIo.stderr === "string") details.push(withIo.stderr); + if (typeof withIo.stdout === "string") details.push(withIo.stdout); + if (withIo.cause instanceof Error) details.push(getErrorText(withIo.cause)); + + return details.join("\n").toLowerCase(); +} + +function isUnknownJsonFieldError(err: unknown, fieldName: string): boolean { + const text = getErrorText(err); + if (!text) return false; + + const unknownFieldSignals = + text.includes("unknown json field") || + text.includes("unknown field") || + text.includes("invalid field"); + + return unknownFieldSignals && text.includes(fieldName.toLowerCase()); +} + +async function ghIssueViewJson(identifier: string, project: ProjectConfig): Promise { + const fieldsWithStateReason = "number,title,body,url,state,stateReason,labels,assignees"; + try { + return await gh([ + "issue", + "view", + identifier, + "--repo", + project.repo, + "--json", + fieldsWithStateReason, + ]); + } catch (err) { + if (!isUnknownJsonFieldError(err, "stateReason")) throw err; + return gh([ + "issue", + "view", + identifier, + "--repo", + project.repo, + "--json", + "number,title,body,url,state,labels,assignees", + ]); + } +} + +async function ghIssueListJson(args: string[]): Promise { + const withStateReason = [ + ...args, + "--json", + "number,title,body,url,state,stateReason,labels,assignees", + ]; + try { + return await gh(withStateReason); + } catch (err) { + if (!isUnknownJsonFieldError(err, "stateReason")) throw err; + return gh([...args, "--json", "number,title,body,url,state,labels,assignees"]); + } +} + function mapState(ghState: string, stateReason?: string | null): Issue["state"] { const s = ghState.toUpperCase(); if (s === "CLOSED") { @@ -54,15 +118,7 @@ function createGitHubTracker(): Tracker { name: "github", async getIssue(identifier: string, project: ProjectConfig): Promise { - const raw = await gh([ - "issue", - "view", - identifier, - "--repo", - project.repo, - "--json", - "number,title,body,url,state,stateReason,labels,assignees", - ]); + const raw = await ghIssueViewJson(identifier, project); const data: { number: number; @@ -70,7 +126,7 @@ function createGitHubTracker(): Tracker { body: string; url: string; state: string; - stateReason: string | null; + stateReason?: string | null; labels: Array<{ name: string }>; assignees: Array<{ login: string }>; } = JSON.parse(raw); @@ -153,8 +209,6 @@ function createGitHubTracker(): Tracker { "list", "--repo", project.repo, - "--json", - "number,title,body,url,state,stateReason,labels,assignees", "--limit", String(filters.limit ?? 30), ]; @@ -175,14 +229,14 @@ function createGitHubTracker(): Tracker { args.push("--assignee", filters.assignee); } - const raw = await gh(args); + const raw = await ghIssueListJson(args); const issues: Array<{ number: number; title: string; body: string; url: string; state: string; - stateReason: string | null; + stateReason?: string | null; labels: Array<{ name: string }>; assignees: Array<{ login: string }>; }> = JSON.parse(raw); diff --git a/packages/plugins/tracker-github/test/index.test.ts b/packages/plugins/tracker-github/test/index.test.ts index ab682a577..10f61e47d 100644 --- a/packages/plugins/tracker-github/test/index.test.ts +++ b/packages/plugins/tracker-github/test/index.test.ts @@ -124,6 +124,41 @@ describe("tracker-github plugin", () => { await expect(tracker.getIssue("999", project)).rejects.toThrow("issue not found"); }); + it("falls back when gh does not support stateReason field", async () => { + mockGhError('gh issue view failed: Unknown JSON field "stateReason"'); + mockGh({ ...sampleIssue, stateReason: undefined }); + + const issue = await tracker.getIssue("123", project); + + expect(issue.state).toBe("open"); + expect(ghMock).toHaveBeenCalledTimes(2); + expect(ghMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([expect.stringContaining("state,stateReason")]), + ); + expect(ghMock.mock.calls[1]?.[1]).toEqual( + expect.arrayContaining([expect.stringContaining("state,labels,assignees")]), + ); + }); + + it("falls back on alternative unknown-field phrasing for stateReason", async () => { + mockGhError("gh issue view failed: invalid field 'stateReason'"); + mockGh({ ...sampleIssue, stateReason: undefined }); + + const issue = await tracker.getIssue("123", project); + + expect(issue.state).toBe("open"); + expect(ghMock).toHaveBeenCalledTimes(2); + }); + + it("does not swallow unrelated unknown-field errors", async () => { + mockGhError('gh issue view failed: Unknown JSON field "milestone"'); + + await expect(tracker.getIssue("123", project)).rejects.toThrow( + 'Unknown JSON field "milestone"', + ); + expect(ghMock).toHaveBeenCalledTimes(1); + }); + it("throws on malformed JSON response", async () => { ghMock.mockResolvedValueOnce({ stdout: "not json{" }); await expect(tracker.getIssue("123", project)).rejects.toThrow(); @@ -279,6 +314,33 @@ describe("tracker-github plugin", () => { expect.any(Object), ); }); + + it("falls back to legacy JSON fields when stateReason is unsupported", async () => { + mockGhError('gh issue list failed: Unknown JSON field "stateReason"'); + mockGh([{ ...sampleIssue, stateReason: undefined }]); + + const issues = await tracker.listIssues!({}, project); + + expect(issues).toHaveLength(1); + expect(issues[0]?.id).toBe("123"); + expect(ghMock).toHaveBeenCalledTimes(2); + expect(ghMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([expect.stringContaining("state,stateReason")]), + ); + expect(ghMock.mock.calls[1]?.[1]).toEqual( + expect.arrayContaining([expect.stringContaining("state,labels,assignees")]), + ); + }); + + it("falls back for list when stateReason error uses alternate wording", async () => { + mockGhError("gh issue list failed: unknown field stateReason"); + mockGh([{ ...sampleIssue, stateReason: undefined }]); + + const issues = await tracker.listIssues!({}, project); + + expect(issues).toHaveLength(1); + expect(ghMock).toHaveBeenCalledTimes(2); + }); }); // ---- updateIssue ------------------------------------------------------- diff --git a/packages/web/package.json b/packages/web/package.json index a8eb02b81..c68f38ade 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,6 +21,7 @@ "dependencies": { "@composio/ao-core": "workspace:*", "@composio/ao-plugin-agent-claude-code": "workspace:*", + "@composio/ao-plugin-agent-opencode": "workspace:*", "@composio/ao-plugin-runtime-tmux": "workspace:*", "@composio/ao-plugin-scm-github": "workspace:*", "@composio/ao-plugin-tracker-github": "workspace:*", diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 9bae0f315..b2467a077 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -1,13 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { + SessionNotFoundError, SessionNotRestorableError, + SessionNotFoundError, type Session, type SessionManager, type OrchestratorConfig, type PluginRegistry, type SCM, } from "@composio/ao-core"; +import * as serialize from "@/lib/serialize"; +import { getSCM } from "@/lib/services"; // ── Mock Data ───────────────────────────────────────────────────────── // Provides test sessions covering the key states the dashboard needs. @@ -73,20 +77,21 @@ const mockSessionManager: SessionManager = { ), kill: vi.fn(async (id: string) => { if (!testSessions.find((s) => s.id === id)) { - throw new Error(`Session ${id} not found`); + throw new SessionNotFoundError(id); } }), send: vi.fn(async (id: string) => { if (!testSessions.find((s) => s.id === id)) { - throw new Error(`Session ${id} not found`); + throw new SessionNotFoundError(id); } }), cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), spawnOrchestrator: vi.fn(), + remap: vi.fn(async () => "ses_mock"), restore: vi.fn(async (id: string) => { const session = testSessions.find((s) => s.id === id); if (!session) { - throw new Error(`Session ${id} not found`); + throw new SessionNotFoundError(id); } // Simulate SessionNotRestorableError for non-terminal sessions if (session.status === "working" && session.activity !== "exited") { @@ -159,8 +164,10 @@ vi.mock("@/lib/services", () => ({ import { GET as sessionsGET } from "@/app/api/sessions/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; +import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; import { POST as killPOST } from "@/app/api/sessions/[id]/kill/route"; import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route"; +import { POST as remapPOST } from "@/app/api/sessions/[id]/remap/route"; import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route"; import { GET as eventsGET } from "@/app/api/events/route"; @@ -214,6 +221,24 @@ describe("API Routes", () => { expect(session).toHaveProperty("activity"); expect(session).toHaveProperty("createdAt"); }); + + it("skips PR enrichment when metadata enrichment hits timeout", async () => { + vi.useFakeTimers(); + + const metadataSpy = vi + .spyOn(serialize, "enrichSessionsMetadata") + .mockImplementation(() => new Promise(() => {})); + + const responsePromise = sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + await vi.advanceTimersByTimeAsync(3_000); + const res = await responsePromise; + + expect(res.status).toBe(200); + expect(getSCM).not.toHaveBeenCalled(); + + metadataSpy.mockRestore(); + vi.useRealTimers(); + }); }); // ── POST /api/spawn ──────────────────────────────────────────────── @@ -286,7 +311,7 @@ describe("API Routes", () => { it("returns 404 for unknown session", async () => { (mockSessionManager.send as ReturnType).mockRejectedValueOnce( - new Error("Session nonexistent not found"), + new SessionNotFoundError("nonexistent"), ); const req = makeRequest("/api/sessions/nonexistent/send", { method: "POST", @@ -332,6 +357,69 @@ describe("API Routes", () => { }); }); + describe("POST /api/sessions/:id/message", () => { + it("sends a message to a valid session", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: JSON.stringify({ message: "Fix the tests" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it("returns 404 for unknown session", async () => { + (mockSessionManager.send as ReturnType).mockRejectedValueOnce( + new SessionNotFoundError("nonexistent"), + ); + + const req = makeRequest("/api/sessions/nonexistent/message", { + method: "POST", + body: JSON.stringify({ message: "hello" }), + headers: { "Content-Type": "application/json" }, + }); + + const res = await messagePOST(req, { params: Promise.resolve({ id: "nonexistent" }) }); + expect(res.status).toBe(404); + }); + + it("returns 400 when message is missing", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/message/); + }); + + it("returns 400 for invalid JSON body", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: "not json", + headers: { "Content-Type": "application/json" }, + }); + const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(400); + }); + + it("returns 400 for control-char-only message", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: JSON.stringify({ message: "\x00\x01\x02" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/empty/); + }); + }); + // ── POST /api/sessions/:id/kill ──────────────────────────────────── describe("POST /api/sessions/:id/kill", () => { @@ -346,7 +434,7 @@ describe("API Routes", () => { it("returns 404 for unknown session", async () => { (mockSessionManager.kill as ReturnType).mockRejectedValueOnce( - new Error("Session nonexistent not found"), + new SessionNotFoundError("nonexistent"), ); const req = makeRequest("/api/sessions/nonexistent/kill", { method: "POST" }); const res = await killPOST(req, { params: Promise.resolve({ id: "nonexistent" }) }); @@ -381,6 +469,38 @@ describe("API Routes", () => { }); }); + describe("POST /api/sessions/:id/remap", () => { + it("remaps a valid session", async () => { + const req = makeRequest("/api/sessions/backend-3/remap", { method: "POST" }); + const res = await remapPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.ok).toBe(true); + expect(data.opencodeSessionId).toBe("ses_mock"); + expect(mockSessionManager.remap).toHaveBeenCalledWith("backend-3", true); + }); + + it("returns 404 when session is missing", async () => { + (mockSessionManager.remap as ReturnType).mockRejectedValueOnce( + new SessionNotFoundError("missing"), + ); + const req = makeRequest("/api/sessions/missing/remap", { method: "POST" }); + const res = await remapPOST(req, { params: Promise.resolve({ id: "missing" }) }); + expect(res.status).toBe(404); + }); + + it("returns 422 for non-opencode sessions", async () => { + (mockSessionManager.remap as ReturnType).mockRejectedValueOnce( + new Error("Session backend-3 is not using the opencode agent"), + ); + const req = makeRequest("/api/sessions/backend-3/remap", { method: "POST" }); + const res = await remapPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + expect(res.status).toBe(422); + const data = await res.json(); + expect(data.error).toMatch(/not using the opencode agent/); + }); + }); + // ── POST /api/prs/:id/merge ──────────────────────────────────────── describe("POST /api/prs/:id/merge", () => { diff --git a/packages/web/src/__tests__/services.test.ts b/packages/web/src/__tests__/services.test.ts new file mode 100644 index 000000000..944cdc3f1 --- /dev/null +++ b/packages/web/src/__tests__/services.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockLoadConfig, + mockRegister, + mockCreateSessionManager, + mockRegistry, + tmuxPlugin, + claudePlugin, + opencodePlugin, + worktreePlugin, + scmPlugin, + trackerGithubPlugin, + trackerLinearPlugin, +} = vi.hoisted(() => { + const mockLoadConfig = vi.fn(); + const mockRegister = vi.fn(); + const mockCreateSessionManager = vi.fn(); + const mockRegistry = { + register: mockRegister, + get: vi.fn(), + list: vi.fn(), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + }; + + return { + mockLoadConfig, + mockRegister, + mockCreateSessionManager, + mockRegistry, + tmuxPlugin: { manifest: { name: "tmux" } }, + claudePlugin: { manifest: { name: "claude-code" } }, + opencodePlugin: { manifest: { name: "opencode" } }, + worktreePlugin: { manifest: { name: "worktree" } }, + scmPlugin: { manifest: { name: "github" } }, + trackerGithubPlugin: { manifest: { name: "github" } }, + trackerLinearPlugin: { manifest: { name: "linear" } }, + }; +}); + +vi.mock("@composio/ao-core", () => ({ + loadConfig: mockLoadConfig, + createPluginRegistry: () => mockRegistry, + createSessionManager: mockCreateSessionManager, +})); + +vi.mock("@composio/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin })); +vi.mock("@composio/ao-plugin-agent-claude-code", () => ({ default: claudePlugin })); +vi.mock("@composio/ao-plugin-agent-opencode", () => ({ default: opencodePlugin })); +vi.mock("@composio/ao-plugin-workspace-worktree", () => ({ default: worktreePlugin })); +vi.mock("@composio/ao-plugin-scm-github", () => ({ default: scmPlugin })); +vi.mock("@composio/ao-plugin-tracker-github", () => ({ default: trackerGithubPlugin })); +vi.mock("@composio/ao-plugin-tracker-linear", () => ({ default: trackerLinearPlugin })); + +describe("services", () => { + beforeEach(() => { + vi.resetModules(); + mockRegister.mockClear(); + mockCreateSessionManager.mockReset(); + mockLoadConfig.mockReset(); + mockLoadConfig.mockReturnValue({ + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects: {}, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, + }); + mockCreateSessionManager.mockReturnValue({}); + delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices; + delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit; + }); + + afterEach(() => { + delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices; + delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit; + }); + + it("registers the OpenCode agent plugin with web services", async () => { + const { getServices } = await import("../lib/services"); + + await getServices(); + + expect(mockRegister).toHaveBeenCalledWith(opencodePlugin); + }); + + it("caches initialized services across repeated calls", async () => { + const { getServices } = await import("../lib/services"); + + const first = await getServices(); + const second = await getServices(); + + expect(first).toBe(second); + expect(mockCreateSessionManager).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/app/api/sessions/[id]/kill/route.ts b/packages/web/src/app/api/sessions/[id]/kill/route.ts index 2668f4064..3a314535c 100644 --- a/packages/web/src/app/api/sessions/[id]/kill/route.ts +++ b/packages/web/src/app/api/sessions/[id]/kill/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; +import { SessionNotFoundError } from "@composio/ao-core"; /** POST /api/sessions/:id/kill — Kill a session */ export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -15,8 +16,10 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< await sessionManager.kill(id); return NextResponse.json({ ok: true, sessionId: id }); } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: err.message }, { status: 404 }); + } const msg = err instanceof Error ? err.message : "Failed to kill session"; - const status = msg.includes("not found") ? 404 : 500; - return NextResponse.json({ error: msg }, { status }); + return NextResponse.json({ error: msg }, { status: 500 }); } } diff --git a/packages/web/src/app/api/sessions/[id]/message/route.ts b/packages/web/src/app/api/sessions/[id]/message/route.ts index b33587a0c..aa8752719 100644 --- a/packages/web/src/app/api/sessions/[id]/message/route.ts +++ b/packages/web/src/app/api/sessions/[id]/message/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { getServices } from "@/lib/services"; import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; -import type { Runtime } from "@composio/ao-core"; +import { SessionNotFoundError } from "@composio/ao-core"; const MAX_MESSAGE_LENGTH = 10_000; @@ -46,34 +46,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ); } - const { sessionManager, registry } = await getServices(); - const session = await sessionManager.get(id); - - if (!session) { - return NextResponse.json({ error: "Session not found" }, { status: 404 }); - } - - if (!session.runtimeHandle) { - return NextResponse.json({ error: "Session has no runtime handle" }, { status: 400 }); - } - - // Get the runtime plugin that was used to create this session - // Use the runtime from the session handle, not from current project config - const runtimeName = session.runtimeHandle.runtimeName; - const runtime = registry.get("runtime", runtimeName); - if (!runtime) { - return NextResponse.json( - { error: `Runtime plugin '${runtimeName}' not found` }, - { status: 500 }, - ); - } - + const { sessionManager } = await getServices(); try { - // Use the Runtime plugin's sendMessage method which handles sanitization - // and uses the correct runtime handle - await runtime.sendMessage(session.runtimeHandle, message); + await sessionManager.send(id, message); return NextResponse.json({ success: true }); } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: err.message }, { status: 404 }); + } const errorMsg = err instanceof Error ? err.message : String(err); console.error("Failed to send message:", errorMsg); return NextResponse.json({ error: `Failed to send message: ${errorMsg}` }, { status: 500 }); diff --git a/packages/web/src/app/api/sessions/[id]/remap/route.ts b/packages/web/src/app/api/sessions/[id]/remap/route.ts new file mode 100644 index 000000000..480bcfc1f --- /dev/null +++ b/packages/web/src/app/api/sessions/[id]/remap/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateIdentifier } from "@/lib/validation"; +import { getServices } from "@/lib/services"; +import { SessionNotFoundError } from "@composio/ao-core"; + +export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const idErr = validateIdentifier(id, "id"); + if (idErr) { + return NextResponse.json({ error: idErr }, { status: 400 }); + } + + try { + const { sessionManager } = await getServices(); + const opencodeSessionId = await sessionManager.remap(id, true); + return NextResponse.json({ ok: true, sessionId: id, opencodeSessionId }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: err.message }, { status: 404 }); + } + const msg = err instanceof Error ? err.message : "Failed to remap session"; + if (msg.includes("not using the opencode agent") || msg.includes("mapping is missing")) { + return NextResponse.json({ error: msg }, { status: 422 }); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/sessions/[id]/restore/route.ts b/packages/web/src/app/api/sessions/[id]/restore/route.ts index 07a034f64..28b86aafa 100644 --- a/packages/web/src/app/api/sessions/[id]/restore/route.ts +++ b/packages/web/src/app/api/sessions/[id]/restore/route.ts @@ -2,7 +2,11 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; -import { SessionNotRestorableError, WorkspaceMissingError } from "@composio/ao-core"; +import { + SessionNotRestorableError, + WorkspaceMissingError, + SessionNotFoundError, +} from "@composio/ao-core"; /** POST /api/sessions/:id/restore — Restore a terminated session */ export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -22,6 +26,9 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< session: sessionToDashboard(restored), }); } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: err.message }, { status: 404 }); + } if (err instanceof SessionNotRestorableError) { return NextResponse.json({ error: err.message }, { status: 409 }); } @@ -29,7 +36,6 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: err.message }, { status: 422 }); } const msg = err instanceof Error ? err.message : "Failed to restore session"; - const status = msg.includes("not found") ? 404 : 500; - return NextResponse.json({ error: msg }, { status }); + return NextResponse.json({ error: msg }, { status: 500 }); } } diff --git a/packages/web/src/app/api/sessions/[id]/send/route.ts b/packages/web/src/app/api/sessions/[id]/send/route.ts index b8b2167b8..0d1bbab64 100644 --- a/packages/web/src/app/api/sessions/[id]/send/route.ts +++ b/packages/web/src/app/api/sessions/[id]/send/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation"; import { getServices } from "@/lib/services"; +import { SessionNotFoundError } from "@composio/ao-core"; const MAX_MESSAGE_LENGTH = 10_000; @@ -34,8 +35,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ await sessionManager.send(id, message); return NextResponse.json({ ok: true, sessionId: id, message }); } catch (err) { + if (err instanceof SessionNotFoundError) { + return NextResponse.json({ error: err.message }, { status: 404 }); + } const msg = err instanceof Error ? err.message : "Failed to send message"; - const status = msg.includes("not found") ? 404 : 500; - return NextResponse.json({ error: msg }, { status }); + return NextResponse.json({ error: msg }, { status: 500 }); } } diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 0671a9155..d9dcae175 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -9,6 +9,25 @@ import { computeStats, } from "@/lib/serialize"; +const METADATA_ENRICH_TIMEOUT_MS = 3_000; +const PR_ENRICH_TIMEOUT_MS = 4_000; +const PER_PR_ENRICH_TIMEOUT_MS = 1_500; + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(false), timeoutMs); + }); + + try { + return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + /** GET /api/sessions — List all sessions with full state * Query params: * - active=true: Only return non-exited sessions @@ -40,20 +59,30 @@ export async function GET(request: Request) { dashboardSessions = activeIndices.map((i) => dashboardSessions[i]); } - // Enrich metadata (issue labels, agent summaries, issue titles) — cap at 3s - const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); - await Promise.race([enrichSessionsMetadata(workerSessions, dashboardSessions, config, registry), metaTimeout]); + const metadataSettled = await settlesWithin( + enrichSessionsMetadata(workerSessions, dashboardSessions, config, registry), + METADATA_ENRICH_TIMEOUT_MS, + ); - // Enrich sessions that have PRs with live SCM data (CI, reviews, mergeability) - const enrichPromises = workerSessions.map((core, i) => { - if (!core.pr) return Promise.resolve(); - const project = resolveProject(core, config.projects); - const scm = getSCM(registry, project); - if (!scm) return Promise.resolve(); - return enrichSessionPR(dashboardSessions[i], scm, core.pr); - }); - const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000)); - await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); + if (metadataSettled) { + const prDeadlineAt = Date.now() + PR_ENRICH_TIMEOUT_MS; + for (let i = 0; i < workerSessions.length; i++) { + const core = workerSessions[i]; + if (!core?.pr) continue; + + const remainingMs = prDeadlineAt - Date.now(); + if (remainingMs <= 0) break; + + const project = resolveProject(core, config.projects); + const scm = getSCM(registry, project); + if (!scm) continue; + + await settlesWithin( + enrichSessionPR(dashboardSessions[i], scm, core.pr), + Math.min(remainingMs, PER_PR_ENRICH_TIMEOUT_MS), + ); + } + } return NextResponse.json({ sessions: dashboardSessions, diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index ab32dc4ee..d14baf02e 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -19,6 +19,8 @@ interface DirectTerminalProps { /** CSS height for the terminal container in normal (non-fullscreen) mode. * Defaults to "max(440px, calc(100vh - 440px))". */ height?: string; + isOpenCodeSession?: boolean; + reloadCommand?: string; } interface DirectTerminalLocation { @@ -70,6 +72,8 @@ export function DirectTerminal({ startFullscreen = false, variant = "agent", height = "max(440px, calc(100vh - 440px))", + isOpenCodeSession = false, + reloadCommand, }: DirectTerminalProps) { const router = useRouter(); const pathname = usePathname(); @@ -85,6 +89,8 @@ export function DirectTerminal({ const [fullscreen, setFullscreen] = useState(startFullscreen); const [status, setStatus] = useState<"connecting" | "connected" | "error">("connecting"); const [error, setError] = useState(null); + const [reloading, setReloading] = useState(false); + const [reloadError, setReloadError] = useState(null); // Update URL when fullscreen changes useEffect(() => { @@ -100,6 +106,45 @@ export function DirectTerminal({ router.replace(newUrl, { scroll: false }); }, [fullscreen, pathname, router, searchParams]); + async function handleReload(): Promise { + if (!isOpenCodeSession || reloading) return; + setReloadError(null); + setReloading(true); + try { + let commandToSend = reloadCommand; + + if (!commandToSend) { + const remapRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/remap`, { + method: "POST", + }); + if (!remapRes.ok) { + throw new Error(`Failed to remap OpenCode session: ${remapRes.status}`); + } + const remapData = (await remapRes.json()) as { opencodeSessionId?: unknown }; + if ( + typeof remapData.opencodeSessionId !== "string" || + remapData.opencodeSessionId.length === 0 + ) { + throw new Error("Missing OpenCode session id after remap"); + } + commandToSend = `/exit\nopencode --session ${remapData.opencodeSessionId}\n`; + } + + const sendRes = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: commandToSend }), + }); + if (!sendRes.ok) { + throw new Error(`Failed to send reload command: ${sendRes.status}`); + } + } catch (err) { + setReloadError(err instanceof Error ? err.message : "Failed to reload OpenCode session"); + } finally { + setReloading(false); + } + } + useEffect(() => { if (!terminalRef.current) return; @@ -127,9 +172,7 @@ export function DirectTerminal({ // agent = blue (#5b7ef8), orchestrator = violet (#a371f7) const cursorColor = variant === "orchestrator" ? "#a371f7" : "#5b7ef8"; const selectionColor = - variant === "orchestrator" - ? "rgba(163, 113, 247, 0.25)" - : "rgba(91, 126, 248, 0.3)"; + variant === "orchestrator" ? "rgba(163, 113, 247, 0.25)" : "rgba(91, 126, 248, 0.3)"; // Initialize xterm.js Terminal const terminal = new Terminal({ @@ -143,22 +186,22 @@ export function DirectTerminal({ cursorAccent: "#0a0a0f", selectionBackground: selectionColor, // ANSI colors — slightly warmer than pure defaults - black: "#1a1a24", - red: "#ef4444", - green: "#22c55e", - yellow: "#f59e0b", - blue: "#5b7ef8", - magenta: "#a371f7", - cyan: "#22d3ee", - white: "#d4d4d8", - brightBlack: "#50506a", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#fbbf24", - brightBlue: "#7b9cfb", + black: "#1a1a24", + red: "#ef4444", + green: "#22c55e", + yellow: "#f59e0b", + blue: "#5b7ef8", + magenta: "#a371f7", + cyan: "#22d3ee", + white: "#d4d4d8", + brightBlack: "#50506a", + brightRed: "#f87171", + brightGreen: "#4ade80", + brightYellow: "#fbbf24", + brightBlue: "#7b9cfb", brightMagenta: "#c084fc", - brightCyan: "#67e8f9", - brightWhite: "#eeeef5", + brightCyan: "#67e8f9", + brightWhite: "#eeeef5", }, scrollback: 10000, allowProposedApi: true, @@ -239,7 +282,10 @@ export function DirectTerminal({ const MAX_BUFFER_BYTES = 1_048_576; // 1 MB const flushWriteBuffer = () => { - if (safetyTimer) { clearTimeout(safetyTimer); safetyTimer = null; } + if (safetyTimer) { + clearTimeout(safetyTimer); + safetyTimer = null; + } if (writeBuffer.length > 0) { terminal.write(writeBuffer.join("")); writeBuffer.length = 0; @@ -502,7 +548,8 @@ export function DirectTerminal({ }; }, [fullscreen]); - const accentColor = variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)"; + const accentColor = + variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)"; const statusDotClass = status === "connected" @@ -512,11 +559,7 @@ export function DirectTerminal({ : "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]"; const statusText = - status === "connected" - ? "Connected" - : status === "error" - ? (error ?? "Error") - : "Connecting…"; + status === "connected" ? "Connected" : status === "error" ? (error ?? "Error") : "Connecting…"; const statusTextColor = status === "connected" @@ -536,13 +579,12 @@ export function DirectTerminal({ {/* Terminal chrome bar */}
- + {sessionId} - + {statusText} {/* XDA clipboard badge */} @@ -555,20 +597,81 @@ export function DirectTerminal({ > XDA + {isOpenCodeSession ? ( + + ) : null} + {reloadError ? ( + + {reloadError} + + ) : null}