diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 000000000..9b44b03a9 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -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 diff --git a/package.json b/package.json index 5eefa1f03..ad56d59a3 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json new file mode 100644 index 000000000..66aaf933b --- /dev/null +++ b/packages/integration-tests/package.json @@ -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" + } +} diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts new file mode 100644 index 000000000..a07c117c6 --- /dev/null +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts new file mode 100644 index 000000000..c4280718b --- /dev/null +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -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 { + 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"); + } + }); +}); diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts new file mode 100644 index 000000000..fab7f8e95 --- /dev/null +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts new file mode 100644 index 000000000..ed1ea2e95 --- /dev/null +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/packages/integration-tests/src/helpers/polling.ts b/packages/integration-tests/src/helpers/polling.ts new file mode 100644 index 000000000..a274170ce --- /dev/null +++ b/packages/integration-tests/src/helpers/polling.ts @@ -0,0 +1,49 @@ +/** + * Polling utilities for integration tests. + */ + +/** Sleep for the given number of milliseconds. */ +export function sleep(ms: number): Promise { + 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( + fn: () => Promise, + opts: { timeoutMs: number; intervalMs?: number }, +): Promise { + 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( + fn: () => Promise, + expected: T, + opts: { timeoutMs: number; intervalMs?: number }, +): Promise { + 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; +} diff --git a/packages/integration-tests/src/helpers/session-factory.ts b/packages/integration-tests/src/helpers/session-factory.ts new file mode 100644 index 000000000..659be1746 --- /dev/null +++ b/packages/integration-tests/src/helpers/session-factory.ts @@ -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: {}, + }; +} diff --git a/packages/integration-tests/src/helpers/tmux.ts b/packages/integration-tests/src/helpers/tmux.ts new file mode 100644 index 000000000..d1a6f444d --- /dev/null +++ b/packages/integration-tests/src/helpers/tmux.ts @@ -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 { + 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 { + 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, +): Promise { + 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 { + 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 { + 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 { + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-t", name, "-p", "-S", `-${lines}`], + { timeout: TIMEOUT }, + ); + return stdout; +} diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json new file mode 100644 index 000000000..34ac657bd --- /dev/null +++ b/packages/integration-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/integration-tests/vitest.config.ts b/packages/integration-tests/vitest.config.ts new file mode 100644 index 000000000..38fc0d1d6 --- /dev/null +++ b/packages/integration-tests/vitest.config.ts @@ -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"], + }, +}); diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json new file mode 100644 index 000000000..ee27196cd --- /dev/null +++ b/packages/plugins/agent-opencode/package.json @@ -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" + } +} diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts new file mode 100644 index 000000000..67c8b2742 --- /dev/null +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts new file mode 100644 index 000000000..e19eb358d --- /dev/null +++ b/packages/plugins/agent-opencode/src/index.ts @@ -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 { + const env: Record = {}; + 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 { + 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 { + 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 { + if (!session.runtimeHandle) return false; + return this.isProcessRunning(session.runtimeHandle); + }, + + async getSessionInfo(_session: Session): Promise { + // 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; diff --git a/packages/plugins/agent-opencode/tsconfig.json b/packages/plugins/agent-opencode/tsconfig.json new file mode 100644 index 000000000..73bc8173c --- /dev/null +++ b/packages/plugins/agent-opencode/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91ed6db78..84a716597 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':