test: add comprehensive unit tests for all core services

Add vitest test suite with 103 tests across 5 test files:

- metadata.test.ts (19 tests): read/write/update/delete/list, key=value
  parsing, comments, values with equals signs, archiving, dotfile exclusion
- event-bus.test.ts (22 tests): emit/on/off, wildcard listeners, handler
  error resilience, priority inference, JSONL persistence round-trip,
  filtered history with sessionId/projectId/type/priority/since/limit
- tmux.test.ts (23 tests): all tmux wrappers with mocked child_process,
  session listing/parsing, send-keys short vs load-buffer long text,
  capture-pane, kill, getPaneTTY
- session-manager.test.ts (23 tests): spawn pipeline with mocked plugins,
  session numbering, branch derivation, event emission, metadata writing,
  list with dead runtime detection, kill with cleanup, send via runtime
- lifecycle-manager.test.ts (16 tests): state transitions (spawning→working,
  killed, stuck, needs_input, ci_failed, merged, mergeable), reaction
  triggering, auto=false suppression, on/off handlers, getStates isolation

Also fixes inferPriority to handle merge.completed as "action" priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-13 17:47:28 +05:30
parent de86054b9d
commit 2b9a28a031
8 changed files with 2585 additions and 754 deletions

View File

@ -18,6 +18,8 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"dependencies": {
@ -26,6 +28,7 @@
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createEventBus, createEvent } from "../event-bus.js";
import type { OrchestratorEvent, EventType } from "../types.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = join(tmpdir(), `ao-test-eventbus-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("createEvent", () => {
it("creates event with all fields", () => {
const event = createEvent("session.spawned", {
sessionId: "app-1",
projectId: "my-app",
message: "Session spawned",
data: { branch: "main" },
});
expect(event.id).toBeTruthy();
expect(event.type).toBe("session.spawned");
expect(event.sessionId).toBe("app-1");
expect(event.projectId).toBe("my-app");
expect(event.message).toBe("Session spawned");
expect(event.data).toEqual({ branch: "main" });
expect(event.timestamp).toBeInstanceOf(Date);
});
it("infers priority from event type", () => {
expect(createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
expect(createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
expect(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
expect(createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
});
it("allows explicit priority override", () => {
const event = createEvent("session.spawned", {
sessionId: "a",
projectId: "p",
message: "",
priority: "urgent",
});
expect(event.priority).toBe("urgent");
});
it("generates unique IDs", () => {
const e1 = createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" });
const e2 = createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" });
expect(e1.id).not.toBe(e2.id);
});
});
describe("createEventBus (no persistence)", () => {
it("emits events to typed handlers", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
bus.on("session.spawned", (e) => received.push(e));
const event = createEvent("session.spawned", {
sessionId: "app-1",
projectId: "p",
message: "spawned",
});
bus.emit(event);
expect(received).toHaveLength(1);
expect(received[0].sessionId).toBe("app-1");
});
it("emits events to wildcard handlers", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
bus.on("*", (e) => received.push(e));
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
expect(received).toHaveLength(2);
});
it("does not deliver events to wrong type", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
bus.on("ci.failing", (e) => received.push(e));
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
expect(received).toHaveLength(0);
});
it("unsubscribes handlers with off()", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
const handler = (e: OrchestratorEvent) => received.push(e);
bus.on("session.spawned", handler);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
expect(received).toHaveLength(1);
bus.off("session.spawned", handler);
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p", message: "" }));
expect(received).toHaveLength(1); // still 1, not 2
});
it("survives handler errors without breaking", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
bus.on("session.spawned", () => { throw new Error("boom"); });
bus.on("session.spawned", (e) => received.push(e));
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
expect(received).toHaveLength(1);
});
it("supports multiple handlers on same event", () => {
const bus = createEventBus(null);
let count = 0;
bus.on("session.spawned", () => count++);
bus.on("session.spawned", () => count++);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
expect(count).toBe(2);
});
});
describe("getHistory", () => {
it("returns all events when no filter", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p2", message: "" }));
const history = bus.getHistory();
expect(history).toHaveLength(2);
});
it("filters by sessionId", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p", message: "" }));
expect(bus.getHistory({ sessionId: "a" })).toHaveLength(1);
});
it("filters by projectId", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p2", message: "" }));
expect(bus.getHistory({ projectId: "p1" })).toHaveLength(1);
});
it("filters by type", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
expect(bus.getHistory({ type: "ci.failing" })).toHaveLength(1);
});
it("filters by priority", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
bus.emit(createEvent("session.stuck", { sessionId: "b", projectId: "p", message: "" }));
expect(bus.getHistory({ priority: "urgent" })).toHaveLength(1);
});
it("filters by since", () => {
const bus = createEventBus(null);
const beforeTime = new Date(Date.now() - 1000);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
const afterTime = new Date(Date.now() + 1000);
expect(bus.getHistory({ since: beforeTime })).toHaveLength(1);
expect(bus.getHistory({ since: afterTime })).toHaveLength(0);
});
it("applies limit (takes last N)", () => {
const bus = createEventBus(null);
for (let i = 0; i < 10; i++) {
bus.emit(createEvent("session.spawned", { sessionId: `s-${i}`, projectId: "p", message: `msg-${i}` }));
}
const result = bus.getHistory({ limit: 3 });
expect(result).toHaveLength(3);
expect(result[0].sessionId).toBe("s-7");
expect(result[2].sessionId).toBe("s-9");
});
it("combines multiple filters", () => {
const bus = createEventBus(null);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p1", message: "" }));
bus.emit(createEvent("ci.failing", { sessionId: "a", projectId: "p1", message: "" }));
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p2", message: "" }));
expect(bus.getHistory({ sessionId: "a", projectId: "p1" })).toHaveLength(2);
expect(bus.getHistory({ sessionId: "a", type: "session.spawned" })).toHaveLength(2);
expect(bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" })).toHaveLength(1);
});
});
describe("JSONL persistence", () => {
it("persists events to JSONL file", () => {
const logPath = join(tmpDir, "events.jsonl");
const bus = createEventBus(logPath);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "hello" }));
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "oops" }));
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n");
expect(lines).toHaveLength(2);
const parsed = JSON.parse(lines[0]);
expect(parsed.type).toBe("session.spawned");
expect(parsed.sessionId).toBe("a");
expect(parsed.timestamp).toBeTruthy();
});
it("loads history from existing JSONL file on creation", () => {
const logPath = join(tmpDir, "existing.jsonl");
const event = createEvent("session.spawned", { sessionId: "old", projectId: "p", message: "old event" });
const serialized = JSON.stringify({ ...event, timestamp: event.timestamp.toISOString() });
require("node:fs").writeFileSync(logPath, serialized + "\n", "utf-8");
const bus = createEventBus(logPath);
const history = bus.getHistory();
expect(history).toHaveLength(1);
expect(history[0].sessionId).toBe("old");
expect(history[0].timestamp).toBeInstanceOf(Date);
});
it("creates log directory if it does not exist", () => {
const logPath = join(tmpDir, "subdir", "nested", "events.jsonl");
const bus = createEventBus(logPath);
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
expect(existsSync(logPath)).toBe(true);
});
it("appends to existing log file", () => {
const logPath = join(tmpDir, "append.jsonl");
const bus1 = createEventBus(logPath);
bus1.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
const bus2 = createEventBus(logPath);
bus2.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
const content = readFileSync(logPath, "utf-8");
const lines = content.trim().split("\n");
expect(lines).toHaveLength(2);
// bus2 should have loaded the first event + emitted the second
expect(bus2.getHistory()).toHaveLength(2);
});
});

View File

@ -0,0 +1,702 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createLifecycleManager } from "../lifecycle-manager.js";
import { createEventBus } from "../event-bus.js";
import { writeMetadata, readMetadataRaw } from "../metadata.js";
import type {
OrchestratorConfig,
PluginRegistry,
SessionManager,
Session,
Runtime,
Agent,
SCM,
Notifier,
EventBus,
OrchestratorEvent,
ActivityState,
PRInfo,
} from "../types.js";
let dataDir: string;
let eventBus: EventBus;
let mockSessionManager: SessionManager;
let mockRuntime: Runtime;
let mockAgent: Agent;
let mockRegistry: PluginRegistry;
let config: OrchestratorConfig;
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: "active",
branch: "feat/test",
issueId: null,
pr: null,
workspacePath: "/tmp/ws",
runtimeHandle: { id: "rt-1", runtimeName: "mock", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 42,
url: "https://github.com/org/repo/pull/42",
title: "Fix things",
owner: "org",
repo: "repo",
branch: "feat/test",
baseBranch: "main",
isDraft: false,
...overrides,
};
}
beforeEach(() => {
dataDir = join(tmpdir(), `ao-test-lifecycle-${randomUUID()}`);
mkdirSync(join(dataDir, "sessions"), { recursive: true });
eventBus = createEventBus(null);
mockRuntime = {
name: "mock",
create: vi.fn(),
destroy: vi.fn(),
sendMessage: vi.fn().mockResolvedValue(undefined),
getOutput: vi.fn(),
isAlive: vi.fn().mockResolvedValue(true),
};
mockAgent = {
name: "mock-agent",
processName: "mock",
getLaunchCommand: vi.fn(),
getEnvironment: vi.fn(),
detectActivity: vi.fn().mockResolvedValue("active" as ActivityState),
isProcessRunning: vi.fn(),
introspect: vi.fn(),
};
mockRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn(),
loadFromConfig: vi.fn(),
};
mockSessionManager = {
spawn: vi.fn(),
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
kill: vi.fn().mockResolvedValue(undefined),
cleanup: vi.fn(),
send: vi.fn().mockResolvedValue(undefined),
};
config = {
dataDir,
worktreeDir: "/tmp/worktrees",
port: 3000,
defaults: {
runtime: "mock",
agent: "mock-agent",
workspace: "mock-ws",
notifiers: ["desktop"],
},
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: "/tmp/my-app",
defaultBranch: "main",
sessionPrefix: "app",
scm: { plugin: "github" },
},
},
notifiers: {},
notificationRouting: {
urgent: ["desktop"],
action: ["desktop"],
warning: [],
info: [],
},
reactions: {},
};
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
describe("start / stop", () => {
it("starts and stops the polling loop", () => {
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
lm.start(60_000);
// Should not throw on double start
lm.start(60_000);
lm.stop();
// Should not throw on double stop
lm.stop();
});
});
describe("check (single session)", () => {
it("detects transition from spawning to working", async () => {
const session = makeSession({ status: "spawning" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
// Write metadata so updateMetadata works
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "spawning",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
eventBus.on("session.working", (e) => received.push(e));
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("working");
expect(received).toHaveLength(1);
expect(received[0].type).toBe("session.working");
// Metadata should be updated
const meta = readMetadataRaw(dataDir, "app-1");
expect(meta!["status"]).toBe("working");
});
it("detects killed state when runtime is dead", async () => {
vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
const session = makeSession({ status: "working" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("killed");
});
it("detects stuck state from agent", async () => {
vi.mocked(mockAgent.detectActivity).mockResolvedValue("blocked");
const session = makeSession({ status: "working" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("stuck");
});
it("detects needs_input from agent", async () => {
vi.mocked(mockAgent.detectActivity).mockResolvedValue("waiting_input");
const session = makeSession({ status: "working" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("needs_input");
});
it("detects PR states from SCM", async () => {
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn().mockResolvedValue("failing"),
getReviews: vi.fn(),
getReviewDecision: vi.fn().mockResolvedValue("none"),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn(),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};
const session = makeSession({ status: "pr_open", pr: makePR() });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "pr_open",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("ci_failed");
});
it("detects merged PR", async () => {
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("merged"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn(),
getReviews: vi.fn(),
getReviewDecision: vi.fn(),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn(),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};
const session = makeSession({ status: "approved", pr: makePR() });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "approved",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("merged");
});
it("detects mergeable when approved + CI green", async () => {
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn().mockResolvedValue("passing"),
getReviews: vi.fn(),
getReviewDecision: vi.fn().mockResolvedValue("approved"),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn().mockResolvedValue({
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
}),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};
const session = makeSession({ status: "pr_open", pr: makePR() });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "pr_open",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("mergeable");
});
it("throws for nonexistent session", async () => {
vi.mocked(mockSessionManager.get).mockResolvedValue(null);
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await expect(lm.check("nonexistent")).rejects.toThrow("not found");
});
it("does not emit event when status unchanged", async () => {
const session = makeSession({ status: "working" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
eventBus.on("*", (e) => received.push(e));
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
// First check: spawning (from states map, empty) → working = transition
// But the session's status is "working" and determineStatus returns "working"
// Since states map is empty, oldStatus = session.status = "working", newStatus = "working"
// So no transition
// We need to seed the states map first
// Actually, let's check twice: first time sets it, second time same = no event
await lm.check("app-1");
const eventsAfterFirst = [...received];
await lm.check("app-1");
// No new events emitted since status didn't change
expect(received.length).toBe(eventsAfterFirst.length);
});
});
describe("reactions", () => {
it("triggers send-to-agent reaction on CI failure", async () => {
config.reactions = {
"ci-failed": {
auto: true,
action: "send-to-agent",
message: "CI is failing. Fix it.",
retries: 2,
escalateAfter: 2,
},
};
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn().mockResolvedValue("failing"),
getReviews: vi.fn(),
getReviewDecision: vi.fn().mockResolvedValue("none"),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn(),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};
const session = makeSession({ status: "pr_open", pr: makePR() });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "pr_open",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "CI is failing. Fix it.");
});
it("does not trigger reaction when auto=false", async () => {
config.reactions = {
"ci-failed": {
auto: false,
action: "send-to-agent",
message: "CI is failing.",
},
};
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("open"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn().mockResolvedValue("failing"),
getReviews: vi.fn(),
getReviewDecision: vi.fn().mockResolvedValue("none"),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn(),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "scm") return mockSCM;
return null;
}),
};
const session = makeSession({ status: "pr_open", pr: makePR() });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "pr_open",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: registryWithSCM,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
expect(mockSessionManager.send).not.toHaveBeenCalled();
});
});
describe("on / off event handlers", () => {
it("subscribes and receives lifecycle events", async () => {
const session = makeSession({ status: "spawning" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "spawning",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
lm.on("session.working", (e) => received.push(e));
await lm.check("app-1");
expect(received).toHaveLength(1);
expect(received[0].type).toBe("session.working");
});
it("unsubscribes handlers", async () => {
const session = makeSession({ status: "spawning" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "spawning",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
const handler = (e: OrchestratorEvent) => received.push(e);
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
lm.on("session.working", handler);
lm.off("session.working", handler);
await lm.check("app-1");
expect(received).toHaveLength(0);
});
it("wildcard handler receives all events", async () => {
const session = makeSession({ status: "spawning" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "spawning",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
lm.on("*", (e) => received.push(e));
await lm.check("app-1");
expect(received.length).toBeGreaterThan(0);
});
});
describe("getStates", () => {
it("returns copy of states map", async () => {
const session = makeSession({ status: "spawning" });
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "spawning",
project: "my-app",
});
const lm = createLifecycleManager({
config,
registry: mockRegistry,
sessionManager: mockSessionManager,
eventBus,
});
await lm.check("app-1");
const states = lm.getStates();
expect(states.get("app-1")).toBe("working");
// Modifying returned map shouldn't affect internal state
states.set("app-1", "killed");
expect(lm.getStates().get("app-1")).toBe("working");
});
});

View File

@ -0,0 +1,255 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import {
readMetadata,
readMetadataRaw,
writeMetadata,
updateMetadata,
deleteMetadata,
listMetadata,
} from "../metadata.js";
let dataDir: string;
beforeEach(() => {
dataDir = join(tmpdir(), `ao-test-metadata-${randomUUID()}`);
mkdirSync(join(dataDir, "sessions"), { recursive: true });
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
describe("writeMetadata + readMetadata", () => {
it("writes and reads basic metadata", () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/worktree",
branch: "feat/test",
status: "working",
});
const meta = readMetadata(dataDir, "app-1");
expect(meta).not.toBeNull();
expect(meta!.worktree).toBe("/tmp/worktree");
expect(meta!.branch).toBe("feat/test");
expect(meta!.status).toBe("working");
});
it("writes and reads optional fields", () => {
writeMetadata(dataDir, "app-2", {
worktree: "/tmp/w",
branch: "main",
status: "pr_open",
issue: "https://linear.app/team/issue/INT-100",
pr: "https://github.com/org/repo/pull/42",
summary: "Implementing feature X",
project: "my-app",
createdAt: "2025-01-01T00:00:00.000Z",
runtimeHandle: '{"id":"tmux-1","runtimeName":"tmux"}',
});
const meta = readMetadata(dataDir, "app-2");
expect(meta).not.toBeNull();
expect(meta!.issue).toBe("https://linear.app/team/issue/INT-100");
expect(meta!.pr).toBe("https://github.com/org/repo/pull/42");
expect(meta!.summary).toBe("Implementing feature X");
expect(meta!.project).toBe("my-app");
expect(meta!.createdAt).toBe("2025-01-01T00:00:00.000Z");
expect(meta!.runtimeHandle).toBe('{"id":"tmux-1","runtimeName":"tmux"}');
});
it("returns null for nonexistent session", () => {
const meta = readMetadata(dataDir, "nonexistent");
expect(meta).toBeNull();
});
it("produces key=value format matching bash scripts", () => {
writeMetadata(dataDir, "app-3", {
worktree: "/tmp/w",
branch: "feat/INT-123",
status: "working",
issue: "https://linear.app/team/issue/INT-123",
});
const content = readFileSync(join(dataDir, "sessions", "app-3"), "utf-8");
expect(content).toContain("worktree=/tmp/w\n");
expect(content).toContain("branch=feat/INT-123\n");
expect(content).toContain("status=working\n");
expect(content).toContain("issue=https://linear.app/team/issue/INT-123\n");
});
it("omits optional fields that are undefined", () => {
writeMetadata(dataDir, "app-4", {
worktree: "/tmp/w",
branch: "main",
status: "spawning",
});
const content = readFileSync(join(dataDir, "sessions", "app-4"), "utf-8");
expect(content).not.toContain("issue=");
expect(content).not.toContain("pr=");
expect(content).not.toContain("summary=");
});
});
describe("readMetadataRaw", () => {
it("reads arbitrary key=value pairs", () => {
writeFileSync(
join(dataDir, "sessions", "raw-1"),
"worktree=/tmp/w\nbranch=main\ncustom_key=custom_value\n",
"utf-8"
);
const raw = readMetadataRaw(dataDir, "raw-1");
expect(raw).not.toBeNull();
expect(raw!["worktree"]).toBe("/tmp/w");
expect(raw!["custom_key"]).toBe("custom_value");
});
it("returns null for nonexistent session", () => {
expect(readMetadataRaw(dataDir, "nope")).toBeNull();
});
it("handles comments and empty lines", () => {
writeFileSync(
join(dataDir, "sessions", "raw-2"),
"# This is a comment\n\nkey1=value1\n\n# Another comment\nkey2=value2\n",
"utf-8"
);
const raw = readMetadataRaw(dataDir, "raw-2");
expect(raw).toEqual({ key1: "value1", key2: "value2" });
});
it("handles values containing equals signs", () => {
writeFileSync(
join(dataDir, "sessions", "raw-3"),
'runtimeHandle={"id":"foo","data":{"key":"val"}}\n',
"utf-8"
);
const raw = readMetadataRaw(dataDir, "raw-3");
expect(raw!["runtimeHandle"]).toBe('{"id":"foo","data":{"key":"val"}}');
});
});
describe("updateMetadata", () => {
it("updates specific fields while preserving others", () => {
writeMetadata(dataDir, "upd-1", {
worktree: "/tmp/w",
branch: "main",
status: "spawning",
});
updateMetadata(dataDir, "upd-1", { status: "working", pr: "https://github.com/org/repo/pull/1" });
const meta = readMetadata(dataDir, "upd-1");
expect(meta!.status).toBe("working");
expect(meta!.pr).toBe("https://github.com/org/repo/pull/1");
expect(meta!.worktree).toBe("/tmp/w");
expect(meta!.branch).toBe("main");
});
it("deletes keys set to empty string", () => {
writeMetadata(dataDir, "upd-2", {
worktree: "/tmp/w",
branch: "main",
status: "working",
summary: "doing stuff",
});
updateMetadata(dataDir, "upd-2", { summary: "" });
const raw = readMetadataRaw(dataDir, "upd-2");
expect(raw!["summary"]).toBeUndefined();
expect(raw!["status"]).toBe("working");
});
it("creates file if it does not exist", () => {
updateMetadata(dataDir, "upd-3", { status: "new", branch: "test" });
const raw = readMetadataRaw(dataDir, "upd-3");
expect(raw).toEqual({ status: "new", branch: "test" });
});
it("ignores undefined values", () => {
writeMetadata(dataDir, "upd-4", {
worktree: "/tmp/w",
branch: "main",
status: "working",
});
updateMetadata(dataDir, "upd-4", { status: "pr_open", summary: undefined });
const meta = readMetadata(dataDir, "upd-4");
expect(meta!.status).toBe("pr_open");
expect(meta!.summary).toBeUndefined();
});
});
describe("deleteMetadata", () => {
it("deletes metadata file and archives it", () => {
writeMetadata(dataDir, "del-1", {
worktree: "/tmp/w",
branch: "main",
status: "working",
});
deleteMetadata(dataDir, "del-1", true);
expect(existsSync(join(dataDir, "sessions", "del-1"))).toBe(false);
const archiveDir = join(dataDir, "sessions", "archive");
expect(existsSync(archiveDir)).toBe(true);
const files = require("node:fs").readdirSync(archiveDir) as string[];
expect(files.length).toBe(1);
expect(files[0]).toMatch(/^del-1_/);
});
it("deletes without archiving when archive=false", () => {
writeMetadata(dataDir, "del-2", {
worktree: "/tmp/w",
branch: "main",
status: "working",
});
deleteMetadata(dataDir, "del-2", false);
expect(existsSync(join(dataDir, "sessions", "del-2"))).toBe(false);
expect(existsSync(join(dataDir, "sessions", "archive"))).toBe(false);
});
it("is a no-op for nonexistent session", () => {
expect(() => deleteMetadata(dataDir, "nope")).not.toThrow();
});
});
describe("listMetadata", () => {
it("lists all session IDs", () => {
writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" });
writeMetadata(dataDir, "app-2", { worktree: "/tmp", branch: "b", status: "s" });
writeMetadata(dataDir, "app-3", { worktree: "/tmp", branch: "c", status: "s" });
const list = listMetadata(dataDir);
expect(list).toHaveLength(3);
expect(list.sort()).toEqual(["app-1", "app-2", "app-3"]);
});
it("excludes archive directory and dotfiles", () => {
writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" });
mkdirSync(join(dataDir, "sessions", "archive"), { recursive: true });
writeFileSync(join(dataDir, "sessions", ".hidden"), "x", "utf-8");
const list = listMetadata(dataDir);
expect(list).toEqual(["app-1"]);
});
it("returns empty array when sessions dir does not exist", () => {
const emptyDir = join(tmpdir(), `ao-test-empty-${randomUUID()}`);
const list = listMetadata(emptyDir);
expect(list).toEqual([]);
// no cleanup needed since dir was never created
});
});

View File

@ -0,0 +1,521 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createSessionManager } from "../session-manager.js";
import { createEventBus } from "../event-bus.js";
import { writeMetadata, readMetadata, listMetadata } from "../metadata.js";
import type {
OrchestratorConfig,
PluginRegistry,
Runtime,
Agent,
Workspace,
Tracker,
SCM,
RuntimeHandle,
EventBus,
OrchestratorEvent,
} from "../types.js";
let dataDir: string;
let eventBus: EventBus;
let mockRuntime: Runtime;
let mockAgent: Agent;
let mockWorkspace: Workspace;
let mockRegistry: PluginRegistry;
let config: OrchestratorConfig;
function makeHandle(id: string): RuntimeHandle {
return { id, runtimeName: "mock", data: {} };
}
beforeEach(() => {
dataDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`);
mkdirSync(join(dataDir, "sessions"), { recursive: true });
eventBus = createEventBus(null);
mockRuntime = {
name: "mock",
create: vi.fn().mockResolvedValue(makeHandle("rt-1")),
destroy: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
getOutput: vi.fn().mockResolvedValue(""),
isAlive: vi.fn().mockResolvedValue(true),
};
mockAgent = {
name: "mock-agent",
processName: "mock",
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
detectActivity: vi.fn().mockResolvedValue("active"),
isProcessRunning: vi.fn().mockResolvedValue(true),
introspect: vi.fn().mockResolvedValue(null),
};
mockWorkspace = {
name: "mock-ws",
create: vi.fn().mockResolvedValue({
path: "/tmp/mock-ws/app-1",
branch: "feat/TEST-1",
sessionId: "app-1",
projectId: "my-app",
}),
destroy: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
};
mockRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string, name: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
list: vi.fn().mockReturnValue([]),
loadBuiltins: vi.fn().mockResolvedValue(undefined),
loadFromConfig: vi.fn().mockResolvedValue(undefined),
};
config = {
dataDir,
worktreeDir: "/tmp/worktrees",
port: 3000,
defaults: {
runtime: "mock",
agent: "mock-agent",
workspace: "mock-ws",
notifiers: ["desktop"],
},
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: "/tmp/my-app",
defaultBranch: "main",
sessionPrefix: "app",
scm: { plugin: "github" },
tracker: { plugin: "github" },
},
},
notifiers: {},
notificationRouting: {
urgent: ["desktop"],
action: ["desktop"],
warning: [],
info: [],
},
reactions: {},
};
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
describe("spawn", () => {
it("creates a session with workspace, runtime, and agent", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const session = await sm.spawn({ projectId: "my-app" });
expect(session.id).toBe("app-1");
expect(session.status).toBe("spawning");
expect(session.projectId).toBe("my-app");
expect(session.runtimeHandle).toEqual(makeHandle("rt-1"));
// Verify workspace was created
expect(mockWorkspace.create).toHaveBeenCalled();
// Verify agent launch command was requested
expect(mockAgent.getLaunchCommand).toHaveBeenCalled();
// Verify runtime was created
expect(mockRuntime.create).toHaveBeenCalled();
});
it("uses issue ID to derive branch name", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
expect(session.branch).toBe("feat/INT-100");
expect(session.issueId).toBe("INT-100");
});
it("uses tracker.branchName when tracker is available", async () => {
const mockTracker: Tracker = {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({}),
isCompleted: vi.fn().mockResolvedValue(false),
issueUrl: vi.fn().mockReturnValue(""),
branchName: vi.fn().mockReturnValue("custom/INT-100-my-feature"),
generatePrompt: vi.fn().mockResolvedValue(""),
};
const registryWithTracker: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
if (slot === "tracker") return mockTracker;
return null;
}),
};
const sm = createSessionManager({
config,
registry: registryWithTracker,
eventBus,
});
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
expect(session.branch).toBe("custom/INT-100-my-feature");
});
it("increments session numbers correctly", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
// Pre-create some metadata to simulate existing sessions
writeMetadata(dataDir, "app-3", { worktree: "/tmp", branch: "b", status: "working" });
writeMetadata(dataDir, "app-7", { worktree: "/tmp", branch: "b", status: "working" });
const session = await sm.spawn({ projectId: "my-app" });
expect(session.id).toBe("app-8");
});
it("emits session.spawned event", async () => {
const received: OrchestratorEvent[] = [];
eventBus.on("session.spawned", (e) => received.push(e));
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.spawn({ projectId: "my-app" });
expect(received).toHaveLength(1);
expect(received[0].type).toBe("session.spawned");
expect(received[0].sessionId).toBe("app-1");
});
it("writes metadata file", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.spawn({ projectId: "my-app", issueId: "INT-42" });
const meta = readMetadata(dataDir, "app-1");
expect(meta).not.toBeNull();
expect(meta!.status).toBe("spawning");
expect(meta!.project).toBe("my-app");
expect(meta!.issue).toBe("INT-42");
});
it("throws for unknown project", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await expect(sm.spawn({ projectId: "nonexistent" })).rejects.toThrow("Unknown project");
});
it("throws when runtime plugin is missing", async () => {
const emptyRegistry: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockReturnValue(null),
};
const sm = createSessionManager({ config, registry: emptyRegistry, eventBus });
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("not found");
});
});
describe("list", () => {
it("lists sessions from metadata", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/w1",
branch: "feat/a",
status: "working",
project: "my-app",
});
writeMetadata(dataDir, "app-2", {
worktree: "/tmp/w2",
branch: "feat/b",
status: "pr_open",
project: "my-app",
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const sessions = await sm.list();
expect(sessions).toHaveLength(2);
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
});
it("filters by project ID", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "a",
status: "working",
project: "my-app",
});
writeMetadata(dataDir, "other-1", {
worktree: "/tmp",
branch: "b",
status: "working",
project: "other",
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const sessions = await sm.list("my-app");
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("app-1");
});
it("marks dead runtimes as killed", async () => {
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const registryWithDead: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return mockAgent;
return null;
}),
};
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "a",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: registryWithDead, eventBus });
const sessions = await sm.list();
expect(sessions[0].status).toBe("killed");
expect(sessions[0].activity).toBe("exited");
});
});
describe("get", () => {
it("returns session by ID", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
pr: "https://github.com/org/repo/pull/42",
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const session = await sm.get("app-1");
expect(session).not.toBeNull();
expect(session!.id).toBe("app-1");
expect(session!.pr).not.toBeNull();
expect(session!.pr!.number).toBe(42);
expect(session!.pr!.url).toBe("https://github.com/org/repo/pull/42");
});
it("returns null for nonexistent session", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
expect(await sm.get("nonexistent")).toBeNull();
});
});
describe("kill", () => {
it("destroys runtime, workspace, and archives metadata", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/ws",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.kill("app-1");
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-1"));
expect(mockWorkspace.destroy).toHaveBeenCalledWith("/tmp/ws");
expect(readMetadata(dataDir, "app-1")).toBeNull(); // archived + deleted
});
it("emits session.killed event", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const received: OrchestratorEvent[] = [];
eventBus.on("session.killed", (e) => received.push(e));
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.kill("app-1");
expect(received).toHaveLength(1);
expect(received[0].sessionId).toBe("app-1");
});
it("throws for nonexistent session", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await expect(sm.kill("nonexistent")).rejects.toThrow("not found");
});
it("tolerates runtime destroy failure", async () => {
const failRuntime: Runtime = {
...mockRuntime,
destroy: vi.fn().mockRejectedValue(new Error("already gone")),
};
const registryWithFail: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return failRuntime;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: registryWithFail, eventBus });
// Should not throw even though runtime.destroy fails
await expect(sm.kill("app-1")).resolves.toBeUndefined();
});
});
describe("cleanup", () => {
it("kills sessions with merged PRs", async () => {
const mockSCM: SCM = {
name: "mock-scm",
detectPR: vi.fn(),
getPRState: vi.fn().mockResolvedValue("merged"),
mergePR: vi.fn(),
closePR: vi.fn(),
getCIChecks: vi.fn(),
getCISummary: vi.fn(),
getReviews: vi.fn(),
getReviewDecision: vi.fn(),
getPendingComments: vi.fn(),
getAutomatedComments: vi.fn(),
getMergeability: vi.fn(),
};
const registryWithSCM: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
if (slot === "scm") return mockSCM;
return null;
}),
};
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "pr_open",
project: "my-app",
pr: "https://github.com/org/repo/pull/10",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: registryWithSCM, eventBus });
const result = await sm.cleanup();
expect(result.killed).toContain("app-1");
expect(result.skipped).toHaveLength(0);
});
it("skips sessions without merged PRs or completed issues", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
const result = await sm.cleanup();
expect(result.killed).toHaveLength(0);
expect(result.skipped).toContain("app-1");
});
it("kills sessions with dead runtimes", async () => {
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const registryWithDead: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: registryWithDead, eventBus });
const result = await sm.cleanup();
expect(result.killed).toContain("app-1");
});
});
describe("send", () => {
it("sends message via runtime.sendMessage", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.send("app-1", "Fix the CI failures");
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
makeHandle("rt-1"),
"Fix the CI failures"
);
});
it("throws for nonexistent session", async () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await expect(sm.send("nope", "hello")).rejects.toThrow("not found");
});
it("throws when no runtime handle", async () => {
writeMetadata(dataDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
project: "my-app",
});
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await expect(sm.send("app-1", "hello")).rejects.toThrow("No runtime handle");
});
});

View File

@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as childProcess from "node:child_process";
import {
isTmuxAvailable,
listSessions,
hasSession,
newSession,
sendKeys,
capturePane,
killSession,
getPaneTTY,
} from "../tmux.js";
// Mock child_process.execFile
vi.mock("node:child_process", () => ({
execFile: vi.fn(),
}));
const mockExecFile = vi.mocked(childProcess.execFile);
/** Helper to make execFile resolve with stdout. */
function mockTmuxSuccess(stdout: string) {
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
(callback as Function)(null, stdout, "");
return {} as any;
});
}
/** Helper to make execFile reject with an error. */
function mockTmuxError(message: string) {
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
(callback as Function)(new Error(message), "", message);
return {} as any;
});
}
/** Helper for sequential tmux calls returning different results. */
function mockTmuxSequence(results: Array<{ stdout?: string; error?: string }>) {
let callIndex = 0;
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
const result = results[callIndex] ?? results[results.length - 1];
callIndex++;
if (result.error) {
(callback as Function)(new Error(result.error), "", result.error);
} else {
(callback as Function)(null, result.stdout ?? "", "");
}
return {} as any;
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("isTmuxAvailable", () => {
it("returns true when tmux server is running", async () => {
mockTmuxSuccess("session1\nsession2\n");
expect(await isTmuxAvailable()).toBe(true);
});
it("returns false when tmux server is not running", async () => {
mockTmuxError("no server running");
expect(await isTmuxAvailable()).toBe(false);
});
});
describe("listSessions", () => {
it("parses tmux session list", async () => {
mockTmuxSuccess(
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" +
"app-2\tTue Jan 2 00:00:00 2025\t1\t1\n"
);
const sessions = await listSessions();
expect(sessions).toHaveLength(2);
expect(sessions[0]).toEqual({
name: "app-1",
created: "Mon Jan 1 00:00:00 2025",
attached: false,
windows: 2,
});
expect(sessions[1]).toEqual({
name: "app-2",
created: "Tue Jan 2 00:00:00 2025",
attached: true,
windows: 1,
});
});
it("returns empty array when no sessions", async () => {
mockTmuxError("no server running on /private/tmp/tmux-501/default");
expect(await listSessions()).toEqual([]);
});
it("handles empty output", async () => {
mockTmuxSuccess("");
expect(await listSessions()).toEqual([]);
});
});
describe("hasSession", () => {
it("returns true when session exists", async () => {
mockTmuxSuccess("");
expect(await hasSession("app-1")).toBe(true);
expect(mockExecFile).toHaveBeenCalledWith(
"tmux",
["has-session", "-t", "app-1"],
expect.any(Object),
expect.any(Function)
);
});
it("returns false when session does not exist", async () => {
mockTmuxError("session not found");
expect(await hasSession("app-99")).toBe(false);
});
});
describe("newSession", () => {
it("creates a basic session", async () => {
mockTmuxSuccess("");
await newSession({ name: "test-1", cwd: "/tmp/workspace" });
expect(mockExecFile).toHaveBeenCalledWith(
"tmux",
["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"],
expect.any(Object),
expect.any(Function)
);
});
it("includes environment variables", async () => {
mockTmuxSuccess("");
await newSession({
name: "test-2",
cwd: "/tmp",
environment: { AO_SESSION: "test-2", SOME_VAR: "value" },
});
const args = mockExecFile.mock.calls[0][1] as string[];
expect(args).toContain("-e");
expect(args).toContain("AO_SESSION=test-2");
expect(args).toContain("SOME_VAR=value");
});
it("includes window size", async () => {
mockTmuxSuccess("");
await newSession({ name: "test-3", cwd: "/tmp", width: 200, height: 50 });
const args = mockExecFile.mock.calls[0][1] as string[];
expect(args).toContain("-x");
expect(args).toContain("200");
expect(args).toContain("-y");
expect(args).toContain("50");
});
it("sends initial command after creation", async () => {
// First call: new-session, Second call: send-keys, Third call: send-keys Enter
mockTmuxSequence([
{ stdout: "" },
{ stdout: "" },
{ stdout: "" },
]);
await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" });
// Should have called new-session then send-keys twice (text + Enter)
expect(mockExecFile).toHaveBeenCalledTimes(3);
const secondCallArgs = mockExecFile.mock.calls[1][1] as string[];
expect(secondCallArgs).toContain("send-keys");
expect(secondCallArgs).toContain("echo hello");
});
});
describe("sendKeys", () => {
it("sends short text with send-keys", async () => {
mockTmuxSequence([{ stdout: "" }, { stdout: "" }]);
await sendKeys("app-1", "hello world");
expect(mockExecFile).toHaveBeenCalledTimes(2);
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
expect(firstArgs).toEqual(["send-keys", "-t", "app-1", "hello world"]);
// Second call is Enter
const secondArgs = mockExecFile.mock.calls[1][1] as string[];
expect(secondArgs).toEqual(["send-keys", "-t", "app-1", "Enter"]);
});
it("skips Enter when pressEnter=false", async () => {
mockTmuxSuccess("");
await sendKeys("app-1", "hello", false);
expect(mockExecFile).toHaveBeenCalledTimes(1);
});
it("uses load-buffer for long text", async () => {
const longText = "a".repeat(250);
mockTmuxSequence([
{ stdout: "" }, // load-buffer
{ stdout: "" }, // paste-buffer
{ stdout: "" }, // send-keys Enter
]);
await sendKeys("app-1", longText);
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
expect(firstArgs[0]).toBe("load-buffer");
const secondArgs = mockExecFile.mock.calls[1][1] as string[];
expect(secondArgs).toEqual(["paste-buffer", "-t", "app-1"]);
});
it("uses load-buffer for multiline text", async () => {
mockTmuxSequence([
{ stdout: "" }, // load-buffer
{ stdout: "" }, // paste-buffer
{ stdout: "" }, // send-keys Enter
]);
await sendKeys("app-1", "line1\nline2");
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
expect(firstArgs[0]).toBe("load-buffer");
});
});
describe("capturePane", () => {
it("captures pane output with default lines", async () => {
mockTmuxSuccess("some output\nfrom tmux\n");
const output = await capturePane("app-1");
expect(output).toBe("some output\nfrom tmux\n");
expect(mockExecFile).toHaveBeenCalledWith(
"tmux",
["capture-pane", "-t", "app-1", "-p", "-S", "-30"],
expect.any(Object),
expect.any(Function)
);
});
it("captures with custom line count", async () => {
mockTmuxSuccess("output\n");
await capturePane("app-1", 50);
const args = mockExecFile.mock.calls[0][1] as string[];
expect(args).toContain("-50");
});
});
describe("killSession", () => {
it("kills a tmux session", async () => {
mockTmuxSuccess("");
await killSession("app-1");
expect(mockExecFile).toHaveBeenCalledWith(
"tmux",
["kill-session", "-t", "app-1"],
expect.any(Object),
expect.any(Function)
);
});
it("throws when session does not exist", async () => {
mockTmuxError("session not found: app-99");
await expect(killSession("app-99")).rejects.toThrow("session not found");
});
});
describe("getPaneTTY", () => {
it("returns TTY for first pane", async () => {
mockTmuxSuccess("/dev/ttys004\n");
const tty = await getPaneTTY("app-1");
expect(tty).toBe("/dev/ttys004");
});
it("returns first TTY when multiple panes", async () => {
mockTmuxSuccess("/dev/ttys004\n/dev/ttys005\n");
const tty = await getPaneTTY("app-1");
expect(tty).toBe("/dev/ttys004");
});
it("returns null when session not found", async () => {
mockTmuxError("session not found");
const tty = await getPaneTTY("nonexistent");
expect(tty).toBeNull();
});
it("returns null for empty output", async () => {
mockTmuxSuccess("");
const tty = await getPaneTTY("app-1");
expect(tty).toBeNull();
});
});

View File

@ -52,7 +52,7 @@ function inferPriority(type: EventType): EventPriority {
if (type.includes("stuck") || type.includes("needs_input") || type.includes("errored")) {
return "urgent";
}
if (type.includes("approved") || type.includes("ready") || type.includes("merged")) {
if (type.includes("approved") || type.includes("ready") || type.includes("merged") || type.includes("completed")) {
return "action";
}
if (type.includes("fail") || type.includes("changes_requested") || type.includes("conflicts")) {

File diff suppressed because it is too large Load Diff