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:
parent
b4c107b39a
commit
a20c3ef703
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue