Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,23 @@ These are primarily invoked by the orchestrator agent running inside a runtime s
```bash
ao spawn [issue] # Spawn an agent (project auto-detected from cwd)
ao spawn 123 --agent codex # Override agent for this session
ao spawn --claim-pr 42 --claim-pr-repo org/repo
ao batch-spawn 101 102 103 # Spawn agents for multiple issues at once
ao send <session> "Fix the tests" # Send instructions to a running agent
ao session ls # List active sessions (terminated hidden)
ao session ls --include-terminated # Include killed/done/merged/errored/cleanup sessions
ao session ls --json # Machine-readable session inventory (see note below)
ao session claim-pr 42 app-1 --repo org/repo
ao session kill <session> # Kill a session
ao session restore <session> # Revive a crashed agent
```

`ao session claim-pr` accepts a bare PR number or a PR URL. When the PR lives
outside the configured project repository, pass `--repo owner/repo`; AO records
the replaced primary PR in `prHistory` metadata before switching the session to
the new PR. `ao spawn --claim-pr` supports the same repository override via
`--claim-pr-repo`.

> **JSON output:** `ao session ls --json` and `ao status --json` emit
> `{ "data": [...], "meta": { "hiddenTerminatedCount": N } }`. Terminated sessions
> (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) are filtered from
Expand Down
54 changes: 36 additions & 18 deletions packages/cli/__tests__/commands/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ beforeEach(() => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand Down Expand Up @@ -475,13 +476,7 @@ describe("session ls", () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue(null);

await program.parseAsync([
"node",
"test",
"session",
"ls",
"--include-terminated",
]);
await program.parseAsync(["node", "test", "session", "ls", "--include-terminated"]);

const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("app-1");
Expand Down Expand Up @@ -513,14 +508,7 @@ describe("session ls", () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue(null);

await program.parseAsync([
"node",
"test",
"session",
"ls",
"--json",
"--include-terminated",
]);
await program.parseAsync(["node", "test", "session", "ls", "--json", "--include-terminated"]);

expect(consoleSpy).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(String(consoleSpy.mock.calls[0][0]));
Expand Down Expand Up @@ -769,7 +757,11 @@ describe("session attach", () => {
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
runtimeHandle: {
id: "hash-app-1",
runtimeName: "process",
data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" },
},
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
Expand Down Expand Up @@ -803,7 +795,9 @@ describe("session attach", () => {
const inputData = Buffer.from("ls\r");
process.stdin.emit("data", inputData);
expect((mockSocket as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalled();
const written = (mockSocket as { write: ReturnType<typeof vi.fn> }).write.mock.calls.at(-1)![0] as Buffer;
const written = (mockSocket as { write: ReturnType<typeof vi.fn> }).write.mock.calls.at(
-1,
)![0] as Buffer;
expect(written.readUInt8(0)).toBe(0x02); // MSG_TERMINAL_INPUT
expect(written.subarray(5).toString()).toBe("ls\r");

Expand Down Expand Up @@ -939,7 +933,11 @@ describe("session attach", () => {
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
runtimeHandle: {
id: "hash-app-1",
runtimeName: "process",
data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" },
},
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
Expand Down Expand Up @@ -979,6 +977,7 @@ describe("session claim-pr", () => {

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", {
assignOnGithub: true,
repoOverride: undefined,
});

const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
Expand All @@ -993,6 +992,25 @@ describe("session claim-pr", () => {

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-7", "42", {
assignOnGithub: undefined,
repoOverride: undefined,
});
});

it("passes --repo through to claimPR", async () => {
await program.parseAsync([
"node",
"test",
"session",
"claim-pr",
"42",
"app-2",
"--repo",
"ComposioHQ/agent-orchestrator",
]);

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", {
assignOnGithub: undefined,
repoOverride: "ComposioHQ/agent-orchestrator",
});
});

Expand Down
115 changes: 86 additions & 29 deletions packages/cli/__tests__/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core";

const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(
() => ({
mockExec: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
mockSessionManager: {
list: vi.fn(),
kill: vi.fn(),
cleanup: vi.fn(),
get: vi.fn(),
spawn: vi.fn(),
spawnOrchestrator: vi.fn(),
send: vi.fn(),
claimPR: vi.fn(),
},
mockGetRunning: vi.fn(),
}),
);
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(() => ({
mockExec: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
mockSessionManager: {
list: vi.fn(),
kill: vi.fn(),
cleanup: vi.fn(),
get: vi.fn(),
spawn: vi.fn(),
spawnOrchestrator: vi.fn(),
send: vi.fn(),
claimPR: vi.fn(),
},
mockGetRunning: vi.fn(),
}));

vi.mock("../../src/lib/shell.js", () => ({
tmux: vi.fn(),
Expand Down Expand Up @@ -534,9 +532,7 @@ describe("spawn command", () => {
it("reports error when spawn fails", async () => {
mockSessionManager.spawn.mockRejectedValue(new Error("worktree creation failed"));

await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
});

it("claims a PR for the spawned session when --claim-pr is provided", async () => {
Expand Down Expand Up @@ -570,6 +566,7 @@ describe("spawn command", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand All @@ -584,6 +581,7 @@ describe("spawn command", () => {
});
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: undefined,
repoOverride: undefined,
});

const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? "");
Expand Down Expand Up @@ -623,25 +621,85 @@ describe("spawn command", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: true,
takenOverFrom: ["app-9"],
});

await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]);

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: true,
repoOverride: undefined,
});
});

it("passes --claim-pr-repo through to claimPR", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};

mockSessionManager.spawn.mockResolvedValue(fakeSession);
mockSessionManager.claimPR.mockResolvedValue({
sessionId: "app-1",
projectId: "my-app",
pr: {
number: 123,
url: "https://github.com/org/repo/pull/123",
title: "Existing PR",
owner: "org",
repo: "repo",
branch: "feat/claimed-pr",
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
});

await program.parseAsync([
"node",
"test",
"spawn",
"--claim-pr",
"123",
"--assign-on-github",
"--claim-pr-repo",
"ComposioHQ/agent-orchestrator",
]);

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: true,
assignOnGithub: undefined,
repoOverride: "ComposioHQ/agent-orchestrator",
});
});

it("rejects --claim-pr-repo without --claim-pr", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "--claim-pr-repo", "org/repo"]),
).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("--claim-pr-repo requires --claim-pr");
});

it("rejects --assign-on-github without --claim-pr", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "--assign-on-github"]),
Expand Down Expand Up @@ -791,6 +849,7 @@ describe("spawn pre-flight checks", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand Down Expand Up @@ -860,7 +919,9 @@ describe("batch-spawn command", () => {
return cmd;
}

function makeFakeSession(overrides: Partial<Session> & Pick<Session, "id" | "projectId">): Session {
function makeFakeSession(
overrides: Partial<Session> & Pick<Session, "id" | "projectId">,
): Session {
return {
status: "spawning",
activity: null,
Expand Down Expand Up @@ -1001,9 +1062,7 @@ describe("spawn daemon-polling enforcement", () => {
it("refuses to spawn when no AO daemon is running", async () => {
mockGetRunning.mockResolvedValue(null);

await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
Expand All @@ -1022,9 +1081,7 @@ describe("spawn daemon-polling enforcement", () => {
projects: ["other-project"],
});

await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
Expand Down
Loading
Loading