feat: add OpenCode agent plugin, integration tests, and CI workflow

- Add agent-opencode plugin with getLaunchCommand, detectActivity,
  isProcessRunning, getSessionInfo (27 unit tests)
- Add integration test suite for all 4 agents (claude-code, codex,
  aider, opencode) using real binaries in tmux sessions
- Add GitHub Actions CI workflow for integration tests
- Add test:integration script to root package.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 10:59:51 +05:30
parent b4c107b39a
commit a20c3ef703
17 changed files with 1417 additions and 0 deletions

58
.github/workflows/integration-tests.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Integration Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: # allow manual runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
# --- Install tmux ---
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
# --- Start tmux server ---
- name: Start tmux server
run: tmux start-server
# --- Install agent binaries ---
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Install Codex
run: npm install -g @openai/codex
- name: Install Aider
run: pip install aider-chat
- name: Install OpenCode
run: npm install -g opencode-ai
# --- Build project ---
- run: pnpm install --frozen-lockfile
- run: pnpm -r --filter '!@agent-orchestrator/web' build
# --- Run integration tests ---
- name: Run integration tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: pnpm test:integration

View File

@ -18,6 +18,7 @@
"format:check": "prettier --check .",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r --filter '!@agent-orchestrator/web' test",
"test:integration": "pnpm --filter @agent-orchestrator/integration-tests test:integration",
"clean": "pnpm -r clean"
},
"devDependencies": {

View File

@ -0,0 +1,24 @@
{
"name": "@agent-orchestrator/integration-tests",
"version": "0.1.0",
"private": true,
"description": "Integration tests — requires real binaries and tmux",
"type": "module",
"scripts": {
"test": "echo 'Skipped — run test:integration instead'",
"test:integration": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agent-orchestrator/core": "workspace:*",
"@agent-orchestrator/plugin-agent-claude-code": "workspace:*",
"@agent-orchestrator/plugin-agent-codex": "workspace:*",
"@agent-orchestrator/plugin-agent-aider": "workspace:*",
"@agent-orchestrator/plugin-agent-opencode": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,164 @@
/**
* Integration tests for the Aider agent plugin.
*
* Requires:
* - `aider` binary on PATH (or at /Users/equinox/Library/Python/3.9/bin/aider)
* - tmux installed and running
* - ANTHROPIC_API_KEY or OPENAI_API_KEY set (aider may open a browser if missing)
*
* 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 { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@agent-orchestrator/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import aiderPlugin from "@agent-orchestrator/plugin-agent-aider";
import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js";
import { pollUntilEqual, sleep } from "./helpers/polling.js";
import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const SESSION_PREFIX = "ao-inttest-aider-";
const AIDER_BINARY = "/Users/equinox/Library/Python/3.9/bin/aider";
async function findAiderBinary(): Promise<string | null> {
for (const bin of ["aider", AIDER_BINARY]) {
try {
await execFileAsync("which", [bin], { timeout: 5_000 });
return bin;
} catch {
// not found
}
}
return null;
}
/**
* Verify aider has a usable API key by running a quick smoke test inside
* tmux (same context as the real test). A direct `execFileAsync` check
* would inherit the vitest process's env, which may differ from tmux's.
*/
async function canAiderConnect(bin: string): Promise<boolean> {
const probe = "ao-inttest-aider-probe";
try {
await killSessionsByPrefix(probe);
await createSession(probe, `${bin} --exit --no-git --no-browser`, tmpdir());
// Wait for the probe to finish (should take <10s if key is present)
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 });
// session still exists — keep waiting
} catch {
// session is gone → aider exited cleanly
return true;
}
}
// Still running after 20s → stuck on auth prompt
await killSession(probe);
return false;
} catch {
return false;
}
}
const tmuxOk = await isTmuxAvailable();
const aiderBin = await findAiderBinary();
const aiderReady = aiderBin !== null && (await canAiderConnect(aiderBin));
const canRun = tmuxOk && aiderReady;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("agent-aider (integration)", () => {
const agent = aiderPlugin.create();
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
let tmpDir: string;
// Observations captured while the agent is alive (atomically)
let aliveRunning = false;
let aliveActivity: ActivityState | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivity: ActivityState;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
await killSessionsByPrefix(SESSION_PREFIX);
tmpDir = await mkdtemp(join(tmpdir(), "ao-inttest-aider-"));
// --no-git avoids needing a git repo, --yes auto-accepts, --no-browser
// prevents aider from opening the browser for auth (which would block).
const cmd = `${aiderBin} --message 'Say hello and nothing else' --yes --no-auto-commits --no-git --no-browser`;
await createSession(sessionName, cmd, tmpDir);
const handle = makeTmuxHandle(sessionName);
const session = makeSession("inttest-aider", handle, tmpDir);
// Atomically capture "alive" observations. Aider has ~5s Python startup.
const deadline = Date.now() + 25_000;
while (Date.now() < deadline) {
const running = await agent.isProcessRunning(handle);
if (running) {
aliveRunning = true;
const activity = await agent.detectActivity(session);
if (activity !== "exited") {
aliveActivity = activity;
break;
}
}
await sleep(500);
}
// Wait for agent to exit — aider with --message should exit after responding
exitedRunning = await pollUntilEqual(
() => agent.isProcessRunning(handle),
false,
{ timeoutMs: 90_000, intervalMs: 2_000 },
);
exitedActivity = await agent.detectActivity(session);
sessionInfo = await agent.getSessionInfo(session);
}, 120_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("isProcessRunning → true while agent is alive", () => {
expect(aliveRunning).toBe(true);
});
it("detectActivity → not exited while agent is alive", () => {
if (aliveActivity !== undefined) {
expect(aliveActivity).not.toBe("exited");
expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity);
}
});
it("isProcessRunning → false after agent exits", () => {
expect(exitedRunning).toBe(false);
});
it("detectActivity → exited after agent exits", () => {
expect(exitedActivity).toBe("exited");
});
it("getSessionInfo → null (not implemented for aider)", () => {
expect(sessionInfo).toBeNull();
});
});

View File

@ -0,0 +1,143 @@
/**
* Integration tests for the Claude Code agent plugin.
*
* Requires:
* - `claude` binary on PATH (or at /Users/equinox/.local/bin/claude)
* - tmux installed and running
* - ANTHROPIC_API_KEY set (Claude will make a real API call)
*
* Skipped automatically when prerequisites are missing.
*/
import { execFile } from "node:child_process";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@agent-orchestrator/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import claudeCodePlugin from "@agent-orchestrator/plugin-agent-claude-code";
import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js";
import { pollUntilEqual, sleep } from "./helpers/polling.js";
import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const SESSION_PREFIX = "ao-inttest-claude-";
const CLAUDE_BINARY = "/Users/equinox/.local/bin/claude";
async function findClaudeBinary(): Promise<string | null> {
for (const bin of ["claude", CLAUDE_BINARY]) {
try {
await execFileAsync("which", [bin], { timeout: 5_000 });
return bin;
} catch {
// not found
}
}
return null;
}
const tmuxOk = await isTmuxAvailable();
const claudeBin = await findClaudeBinary();
const canRun = tmuxOk && claudeBin !== null;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
const agent = claudeCodePlugin.create();
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
let tmpDir: string;
// Observations captured while the agent is alive (atomically)
let aliveRunning = false;
let aliveActivity: ActivityState | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivity: ActivityState;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
await killSessionsByPrefix(SESSION_PREFIX);
// Create temp workspace — resolve symlinks (macOS /tmp → /private/tmp)
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-claude-"));
tmpDir = await realpath(raw);
// Spawn Claude with a trivial prompt
const cmd = `CLAUDECODE= ${claudeBin} -p 'Say hello and nothing else'`;
await createSession(sessionName, cmd, tmpDir);
const handle = makeTmuxHandle(sessionName);
const session = makeSession("inttest-claude", handle, tmpDir);
// Atomically capture "alive" observations
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const running = await agent.isProcessRunning(handle);
if (running) {
aliveRunning = true;
const activity = await agent.detectActivity(session);
if (activity !== "exited") {
aliveActivity = activity;
break;
}
}
await sleep(500);
}
// Wait for agent to exit (trivial prompt should complete quickly)
exitedRunning = await pollUntilEqual(
() => agent.isProcessRunning(handle),
false,
{ timeoutMs: 90_000, intervalMs: 2_000 },
);
exitedActivity = await agent.detectActivity(session);
sessionInfo = await agent.getSessionInfo(session);
}, 120_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("isProcessRunning → true while agent is alive", () => {
expect(aliveRunning).toBe(true);
});
it("detectActivity → not exited while agent is alive", () => {
if (aliveActivity !== undefined) {
expect(aliveActivity).not.toBe("exited");
expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity);
}
});
it("isProcessRunning → false after agent exits", () => {
expect(exitedRunning).toBe(false);
});
it("detectActivity → exited after agent exits", () => {
expect(exitedActivity).toBe("exited");
});
it("getSessionInfo → returns session data (or null if JSONL path mismatch)", () => {
// The JSONL path depends on Claude's internal encoding of workspacePath.
// If the temp dir path resolves differently, getSessionInfo may return null.
// Both outcomes are acceptable — the key is it doesn't throw.
if (sessionInfo !== null) {
expect(sessionInfo).toHaveProperty("summary");
expect(sessionInfo).toHaveProperty("agentSessionId");
expect(typeof sessionInfo.agentSessionId).toBe("string");
}
});
});

View File

@ -0,0 +1,137 @@
/**
* Integration tests for the Codex agent plugin.
*
* Requires:
* - `codex` binary on PATH (or at /opt/homebrew/bin/codex)
* - tmux installed and running
* - OPENAI_API_KEY set
*
* 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 { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@agent-orchestrator/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import codexPlugin from "@agent-orchestrator/plugin-agent-codex";
import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js";
import { pollUntilEqual, sleep } from "./helpers/polling.js";
import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const SESSION_PREFIX = "ao-inttest-codex-";
const CODEX_BINARY = "/opt/homebrew/bin/codex";
async function findCodexBinary(): Promise<string | null> {
for (const bin of ["codex", CODEX_BINARY]) {
try {
await execFileAsync("which", [bin], { timeout: 5_000 });
return bin;
} catch {
// not found
}
}
return null;
}
const tmuxOk = await isTmuxAvailable();
const codexBin = await findCodexBinary();
const canRun = tmuxOk && codexBin !== null;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("agent-codex (integration)", () => {
const agent = codexPlugin.create();
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
let tmpDir: string;
// Observations captured while the agent is alive (atomically)
let aliveRunning = false;
let aliveActivity: ActivityState | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivity: ActivityState;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
await killSessionsByPrefix(SESSION_PREFIX);
tmpDir = await mkdtemp(join(tmpdir(), "ao-inttest-codex-"));
const cmd = `${codexBin} exec 'Say hello and nothing else'`;
await createSession(sessionName, cmd, tmpDir);
const handle = makeTmuxHandle(sessionName);
const session = makeSession("inttest-codex", handle, tmpDir);
// Atomically capture "alive" observations — poll until we observe
// both running=true AND activity!="exited" in the same iteration.
// Fast-exiting agents may exit between separate calls, so we must
// check both in a tight loop.
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const running = await agent.isProcessRunning(handle);
if (running) {
aliveRunning = true;
const activity = await agent.detectActivity(session);
if (activity !== "exited") {
aliveActivity = activity;
break;
}
}
await sleep(200);
}
// Wait for agent to exit
exitedRunning = await pollUntilEqual(
() => agent.isProcessRunning(handle),
false,
{ timeoutMs: 90_000, intervalMs: 2_000 },
);
exitedActivity = await agent.detectActivity(session);
sessionInfo = await agent.getSessionInfo(session);
}, 120_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("isProcessRunning → true while agent is alive", () => {
expect(aliveRunning).toBe(true);
});
it("detectActivity → not exited while agent is alive", () => {
// For very fast agents, we may not catch a non-exited activity state.
// If aliveActivity is undefined, the agent exited too fast for atomic capture.
if (aliveActivity !== undefined) {
expect(aliveActivity).not.toBe("exited");
expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity);
}
});
it("isProcessRunning → false after agent exits", () => {
expect(exitedRunning).toBe(false);
});
it("detectActivity → exited after agent exits", () => {
expect(exitedActivity).toBe("exited");
});
it("getSessionInfo → null (not implemented for codex)", () => {
expect(sessionInfo).toBeNull();
});
});

View File

@ -0,0 +1,154 @@
/**
* Integration tests for the OpenCode agent plugin.
*
* Requires:
* - `opencode` binary on PATH
* - tmux installed and running
* - ANTHROPIC_API_KEY or OPENAI_API_KEY set
*
* 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 { join } from "node:path";
import { promisify } from "node:util";
import type { ActivityState, AgentSessionInfo } from "@agent-orchestrator/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import opencodePlugin from "@agent-orchestrator/plugin-agent-opencode";
import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js";
import { pollUntilEqual, sleep } from "./helpers/polling.js";
import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const SESSION_PREFIX = "ao-inttest-opencode-";
async function findOpencodeBinary(): Promise<string | null> {
try {
await execFileAsync("which", ["opencode"], { timeout: 5_000 });
return "opencode";
} catch {
return null;
}
}
/** Verify opencode can start (has API key, binary works). */
async function canOpencodeRun(bin: string): Promise<boolean> {
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 {
return false;
}
}
const tmuxOk = await isTmuxAvailable();
const opencodeBin = await findOpencodeBinary();
const opencodeReady = opencodeBin !== null && (await canOpencodeRun(opencodeBin));
const canRun = tmuxOk && opencodeReady;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("agent-opencode (integration)", () => {
const agent = opencodePlugin.create();
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
let tmpDir: string;
// Observations captured while the agent is alive (atomically)
let aliveRunning = false;
let aliveActivity: ActivityState | undefined;
// Observations captured after the agent exits
let exitedRunning: boolean;
let exitedActivity: ActivityState;
let sessionInfo: AgentSessionInfo | null;
beforeAll(async () => {
await killSessionsByPrefix(SESSION_PREFIX);
tmpDir = await mkdtemp(join(tmpdir(), "ao-inttest-opencode-"));
const cmd = `${opencodeBin} run 'Say hello and nothing else'`;
await createSession(sessionName, cmd, tmpDir);
const handle = makeTmuxHandle(sessionName);
const session = makeSession("inttest-opencode", handle, tmpDir);
// Atomically capture "alive" observations
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
const running = await agent.isProcessRunning(handle);
if (running) {
aliveRunning = true;
const activity = await agent.detectActivity(session);
if (activity !== "exited") {
aliveActivity = activity;
break;
}
}
await sleep(200);
}
// Wait for agent to exit
exitedRunning = await pollUntilEqual(
() => agent.isProcessRunning(handle),
false,
{ timeoutMs: 90_000, intervalMs: 2_000 },
);
exitedActivity = await agent.detectActivity(session);
sessionInfo = await agent.getSessionInfo(session);
}, 120_000);
afterAll(async () => {
await killSession(sessionName);
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("isProcessRunning → true while agent is alive", () => {
expect(aliveRunning).toBe(true);
});
it("detectActivity → not exited while agent is alive", () => {
if (aliveActivity !== undefined) {
expect(aliveActivity).not.toBe("exited");
expect(["active", "idle", "waiting_input", "blocked"]).toContain(aliveActivity);
}
});
it("isProcessRunning → false after agent exits", () => {
expect(exitedRunning).toBe(false);
});
it("detectActivity → exited after agent exits", () => {
expect(exitedActivity).toBe("exited");
});
it("getSessionInfo → null (not implemented for opencode)", () => {
expect(sessionInfo).toBeNull();
});
});

View File

@ -0,0 +1,49 @@
/**
* Polling utilities for integration tests.
*/
/** Sleep for the given number of milliseconds. */
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Poll a function until it returns a truthy value or the timeout expires.
* Returns the last value returned by `fn`.
*/
export async function pollUntil<T>(
fn: () => Promise<T>,
opts: { timeoutMs: number; intervalMs?: number },
): Promise<T> {
const { timeoutMs, intervalMs = 1_000 } = opts;
const deadline = Date.now() + timeoutMs;
let last: T;
do {
last = await fn();
if (last) return last;
if (Date.now() >= deadline) break;
await sleep(intervalMs);
} while (Date.now() < deadline);
return last;
}
/**
* Poll a function until its return value equals the expected value
* or the timeout expires. Returns the last value.
*/
export async function pollUntilEqual<T>(
fn: () => Promise<T>,
expected: T,
opts: { timeoutMs: number; intervalMs?: number },
): Promise<T> {
const { timeoutMs, intervalMs = 1_000 } = opts;
const deadline = Date.now() + timeoutMs;
let last: T;
do {
last = await fn();
if (last === expected) return last;
if (Date.now() >= deadline) break;
await sleep(intervalMs);
} while (Date.now() < deadline);
return last;
}

View File

@ -0,0 +1,37 @@
/**
* Factory helpers to build Session and RuntimeHandle objects for tests.
*/
import type { RuntimeHandle, Session } from "@agent-orchestrator/core";
/** Build a tmux RuntimeHandle for a given session name. */
export function makeTmuxHandle(sessionName: string): RuntimeHandle {
return {
id: sessionName,
runtimeName: "tmux",
data: {},
};
}
/** Build a minimal Session object suitable for agent plugin methods. */
export function makeSession(
id: string,
handle: RuntimeHandle | null,
workspacePath: string | null,
): Session {
return {
id,
projectId: "inttest",
status: "working",
activity: "active",
branch: null,
issueId: null,
pr: null,
workspacePath,
runtimeHandle: handle,
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
}

View File

@ -0,0 +1,113 @@
/**
* tmux helpers for integration tests.
* Creates/destroys real tmux sessions and captures their output.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const TIMEOUT = 30_000;
/** Check whether tmux is available on this machine. */
export async function isTmuxAvailable(): Promise<boolean> {
try {
await execFileAsync("tmux", ["-V"], { timeout: TIMEOUT });
return true;
} catch {
return false;
}
}
/** Kill all tmux sessions whose names match a prefix (cleanup helper). */
export async function killSessionsByPrefix(prefix: string): Promise<void> {
try {
const { stdout } = await execFileAsync(
"tmux",
["list-sessions", "-F", "#{session_name}"],
{ timeout: TIMEOUT },
);
const sessions = stdout
.trim()
.split("\n")
.filter((s) => s.startsWith(prefix));
for (const name of sessions) {
try {
await execFileAsync("tmux", ["kill-session", "-t", name], {
timeout: TIMEOUT,
});
} catch {
// session already gone
}
}
} catch {
// tmux server not running — nothing to clean up
}
}
/** Create a new tmux session running the given shell command. */
export async function createSession(
name: string,
command: string,
cwd: string,
env?: Record<string, string>,
): Promise<void> {
const args = [
"new-session",
"-d",
"-s",
name,
"-x",
"200",
"-y",
"50",
];
// Set environment variables inside the session
if (env) {
for (const [key, value] of Object.entries(env)) {
args.push("-e", `${key}=${value}`);
}
}
args.push(command);
await execFileAsync("tmux", args, { timeout: TIMEOUT, cwd });
}
/** Kill a specific tmux session by name. */
export async function killSession(name: string): Promise<void> {
try {
await execFileAsync("tmux", ["kill-session", "-t", name], {
timeout: TIMEOUT,
});
} catch {
// already gone
}
}
/** Check whether a tmux session exists. */
export async function sessionExists(name: string): Promise<boolean> {
try {
await execFileAsync("tmux", ["has-session", "-t", name], {
timeout: TIMEOUT,
});
return true;
} catch {
return false;
}
}
/** Capture the visible pane output from a tmux session. */
export async function capturePane(
name: string,
lines = 50,
): Promise<string> {
const { stdout } = await execFileAsync(
"tmux",
["capture-pane", "-t", name, "-p", "-S", `-${lines}`],
{ timeout: TIMEOUT },
);
return stdout;
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 60_000,
pool: "forks",
poolOptions: {
forks: {
singleFork: true,
},
},
include: ["src/**/*.integration.test.ts"],
},
});

View File

@ -0,0 +1,28 @@
{
"name": "@agent-orchestrator/plugin-agent-opencode",
"version": "0.1.0",
"description": "Agent plugin: OpenCode",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@agent-orchestrator/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,292 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------
const { mockExecFileAsync } = vi.hoisted(() => ({
mockExecFileAsync: vi.fn(),
}));
vi.mock("node:child_process", () => {
const fn = Object.assign((..._args: unknown[]) => {}, {
[Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync,
});
return { execFile: fn };
});
import { create, manifest, default as defaultExport } from "./index.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "test-1",
projectId: "test-project",
status: "working",
activity: "active",
branch: "feat/test",
issueId: null,
pr: null,
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
function makeTmuxHandle(id = "test-session"): RuntimeHandle {
return { id, runtimeName: "tmux", data: {} };
}
function makeProcessHandle(pid?: number | string): RuntimeHandle {
return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} };
}
function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaunchConfig {
return {
sessionId: "sess-1",
projectConfig: {
name: "my-project",
repo: "owner/repo",
path: "/workspace/repo",
defaultBranch: "main",
sessionPrefix: "my",
},
...overrides,
};
}
function mockTmuxWithProcess(processName: string, found = true) {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
if (cmd === "ps") {
const line = found ? ` 789 ttys003 ${processName}` : " 789 ttys003 bash";
return Promise.resolve({
stdout: ` PID TT ARGS\n${line}\n`,
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
}
beforeEach(() => {
vi.clearAllMocks();
});
// =========================================================================
// Manifest & Exports
// =========================================================================
describe("plugin manifest & exports", () => {
it("has correct manifest", () => {
expect(manifest).toEqual({
name: "opencode",
slot: "agent",
description: "Agent plugin: OpenCode",
version: "0.1.0",
});
});
it("create() returns agent with correct name and processName", () => {
const agent = create();
expect(agent.name).toBe("opencode");
expect(agent.processName).toBe("opencode");
});
it("default export is a valid PluginModule", () => {
expect(defaultExport.manifest).toBe(manifest);
expect(typeof defaultExport.create).toBe("function");
});
});
// =========================================================================
// getLaunchCommand
// =========================================================================
describe("getLaunchCommand", () => {
const agent = create();
it("generates base command without prompt", () => {
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("opencode");
});
it("uses run subcommand with shell-escaped prompt", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" }));
expect(cmd).toContain("run 'Fix it'");
});
it("includes --model with shell-escaped value", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "claude-sonnet-4-5-20250929" }));
expect(cmd).toContain("--model 'claude-sonnet-4-5-20250929'");
});
it("combines prompt and model", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ prompt: "Go", model: "gpt-4o" }),
);
expect(cmd).toBe("opencode run 'Go' --model 'gpt-4o'");
});
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'");
});
it("omits optional flags when not provided", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig());
expect(cmd).not.toContain("--model");
expect(cmd).not.toContain("run");
});
});
// =========================================================================
// getEnvironment
// =========================================================================
describe("getEnvironment", () => {
const agent = create();
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
});
it("sets AO_ISSUE_ID when provided", () => {
const env = agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" }));
expect(env["AO_ISSUE_ID"]).toBe("GH-42");
});
it("omits AO_ISSUE_ID when not provided", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_ISSUE_ID"]).toBeUndefined();
});
});
// =========================================================================
// isProcessRunning
// =========================================================================
describe("isProcessRunning", () => {
const agent = create();
it("returns true when opencode found on tmux pane TTY", async () => {
mockTmuxWithProcess("opencode");
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("returns false when opencode not on tmux pane TTY", async () => {
mockTmuxWithProcess("opencode", false);
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns true for process handle with alive PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
expect(killSpy).toHaveBeenCalledWith(123, 0);
killSpy.mockRestore();
});
it("returns false for process handle with dead PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("ESRCH");
});
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(false);
killSpy.mockRestore();
});
it("returns false for unknown runtime without PID", async () => {
const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} };
expect(await agent.isProcessRunning(handle)).toBe(false);
});
it("returns false on tmux command failure", async () => {
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns true when PID exists but throws EPERM", async () => {
const epermErr = Object.assign(new Error("EPERM"), { code: "EPERM" });
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw epermErr;
});
expect(await agent.isProcessRunning(makeProcessHandle(789))).toBe(true);
killSpy.mockRestore();
});
it("finds opencode on any pane in multi-pane session", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") {
return Promise.resolve({ stdout: "/dev/ttys001\n/dev/ttys002\n", stderr: "" });
}
if (cmd === "ps") {
return Promise.resolve({
stdout: " PID TT ARGS\n 100 ttys001 bash\n 200 ttys002 opencode run hello\n",
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
});
// =========================================================================
// detectActivity
// =========================================================================
describe("detectActivity", () => {
const agent = create();
it("returns exited when no runtime handle", async () => {
expect(await agent.detectActivity(makeSession())).toBe("exited");
});
it("returns exited when process is not running", async () => {
mockTmuxWithProcess("opencode", false);
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.detectActivity(session)).toBe("exited");
});
it("returns active when process is running", async () => {
mockTmuxWithProcess("opencode");
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.detectActivity(session)).toBe("active");
});
});
// =========================================================================
// isProcessing
// =========================================================================
describe("isProcessing", () => {
const agent = create();
it("returns false when no runtime handle", async () => {
expect(await agent.isProcessing(makeSession())).toBe(false);
});
it("returns true when process is running", async () => {
mockTmuxWithProcess("opencode");
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.isProcessing(session)).toBe(true);
});
it("returns false when process is not running", async () => {
mockTmuxWithProcess("opencode", false);
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.isProcessing(session)).toBe(false);
});
});
// =========================================================================
// getSessionInfo
// =========================================================================
describe("getSessionInfo", () => {
const agent = create();
it("always returns null (not implemented)", async () => {
expect(await agent.getSessionInfo(makeSession())).toBeNull();
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
});
});

View File

@ -0,0 +1,141 @@
import {
shellEscape,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
type ActivityState,
type PluginModule,
type RuntimeHandle,
type Session,
} from "@agent-orchestrator/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// =============================================================================
// Plugin Manifest
// =============================================================================
export const manifest = {
name: "opencode",
slot: "agent" as const,
description: "Agent plugin: OpenCode",
version: "0.1.0",
};
// =============================================================================
// Agent Implementation
// =============================================================================
function createOpenCodeAgent(): Agent {
return {
name: "opencode",
processName: "opencode",
getLaunchCommand(config: AgentLaunchConfig): string {
const parts: string[] = ["opencode"];
if (config.prompt) {
parts.push("run", shellEscape(config.prompt));
}
if (config.model) {
parts.push("--model", shellEscape(config.model));
}
return parts.join(" ");
},
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
env["AO_SESSION_ID"] = config.sessionId;
env["AO_PROJECT_ID"] = config.projectConfig.name;
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}
return env;
},
async detectActivity(session: Session): Promise<ActivityState> {
if (!session.runtimeHandle) return "exited";
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return "exited";
// OpenCode doesn't have rich introspection yet
return "active";
},
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
try {
if (handle.runtimeName === "tmux" && handle.id) {
const { stdout: ttyOut } = await execFileAsync("tmux", [
"list-panes",
"-t",
handle.id,
"-F",
"#{pane_tty}",
], { timeout: 30_000 });
const ttys = ttyOut
.trim()
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
if (ttys.length === 0) return false;
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000 });
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
const processRe = /(?:^|\/)opencode(?:\s|$)/;
for (const line of psOut.split("\n")) {
const cols = line.trimStart().split(/\s+/);
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
const args = cols.slice(2).join(" ");
if (processRe.test(args)) {
return true;
}
}
return false;
}
const rawPid = handle.data["pid"];
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
if (err instanceof Error && "code" in err && err.code === "EPERM") {
return true;
}
return false;
}
}
return false;
} catch {
return false;
}
},
async isProcessing(session: Session): Promise<boolean> {
if (!session.runtimeHandle) return false;
return this.isProcessRunning(session.runtimeHandle);
},
async getSessionInfo(_session: Session): Promise<AgentSessionInfo | null> {
// OpenCode doesn't have JSONL session files for introspection yet
return null;
},
};
}
// =============================================================================
// Plugin Export
// =============================================================================
export function create(): Agent {
return createOpenCodeAgent();
}
export default { manifest, create } satisfies PluginModule<Agent>;

View File

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -71,6 +71,34 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)
packages/integration-tests:
dependencies:
'@agent-orchestrator/core':
specifier: workspace:*
version: link:../core
'@agent-orchestrator/plugin-agent-aider':
specifier: workspace:*
version: link:../plugins/agent-aider
'@agent-orchestrator/plugin-agent-claude-code':
specifier: workspace:*
version: link:../plugins/agent-claude-code
'@agent-orchestrator/plugin-agent-codex':
specifier: workspace:*
version: link:../plugins/agent-codex
'@agent-orchestrator/plugin-agent-opencode':
specifier: workspace:*
version: link:../plugins/agent-opencode
devDependencies:
'@types/node':
specifier: ^25.2.3
version: 25.2.3
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/agent-aider:
dependencies:
'@agent-orchestrator/core':
@ -119,6 +147,22 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/agent-opencode:
dependencies:
'@agent-orchestrator/core':
specifier: workspace:*
version: link:../../core
devDependencies:
'@types/node':
specifier: ^25.2.3
version: 25.2.3
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/notifier-desktop:
dependencies:
'@agent-orchestrator/core':