feat(FN-986): add agent stop/start CLI commands and extension tools

- Add `fn agent stop <id>` and `fn agent start <id>` CLI commands for pausing/resuming agents
- Add `kb_agent_stop` and `kb_agent_start` tools to the pi extension for in-session agent control
- Validate state transitions using AGENT_VALID_TRANSITIONS before applying changes
- Export AgentStore from @fusion/core public API
- Add comprehensive tests for agent CLI commands (210 lines)
- Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
gsxdsm
2026-04-05 16:15:22 -07:00
parent 11dfcaf7e2
commit f6bd237f46
11 changed files with 498 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ── Mock AgentStore ──────────────────────────────────────────────────
const mockGetAgent = vi.fn();
const mockUpdateAgentState = vi.fn();
const mockInit = vi.fn().mockResolvedValue(undefined);
// AgentStore mock — vi.fn() with mockImplementation works with `new` in vitest.
// We return a plain object from the constructor which becomes the instance.
vi.mock("@fusion/core", () => ({
AgentStore: vi.fn().mockImplementation(() => ({
init: mockInit,
getAgent: mockGetAgent,
updateAgentState: mockUpdateAgentState,
})),
AGENT_VALID_TRANSITIONS: {
idle: ["active"],
active: ["running", "paused", "terminated"],
running: ["active", "paused", "error", "terminated"],
paused: ["active", "terminated"],
error: ["active", "terminated"],
terminated: ["idle", "active", "running"],
},
}));
// ── Mock project-context ─────────────────────────────────────────────
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "test-project",
projectPath: "/tmp/test-project",
projectName: "test-project",
isRegistered: true,
store: {},
}),
}));
// ── Spies ────────────────────────────────────────────────────────────
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as any);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// ── Import after mocks ───────────────────────────────────────────────
import { runAgentStop, runAgentStart } from "../agent.js";
function makeAgent(state: string) {
return {
id: "agent-test123",
name: "test-agent",
role: "executor" as const,
state,
taskId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
};
}
// ── Tests ────────────────────────────────────────────────────────────
describe("runAgentStop", () => {
beforeEach(() => {
mockGetAgent.mockResolvedValue(makeAgent("running"));
mockUpdateAgentState.mockResolvedValue(makeAgent("paused"));
mockInit.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should stop a running agent", async () => {
await runAgentStop("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "paused");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 stopped"));
});
it("should stop an active agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("active"));
await runAgentStop("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "paused");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 stopped"));
});
it("should report when agent is not found", async () => {
mockGetAgent.mockResolvedValue(null);
await expect(runAgentStop("agent-nonexistent")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("agent-nonexistent not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should report when agent is already paused", async () => {
mockGetAgent.mockResolvedValue(makeAgent("paused"));
await runAgentStop("agent-test123");
// Should NOT call updateAgentState
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already paused"));
});
it("should reject stopping an idle agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("idle"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should reject stopping an error agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("error"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should reject stopping a terminated agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runAgentStart", () => {
beforeEach(() => {
mockGetAgent.mockResolvedValue(makeAgent("paused"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
mockInit.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should start a paused agent", async () => {
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start a terminated agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start an idle agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("idle"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start an error agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("error"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should report when agent is not found", async () => {
mockGetAgent.mockResolvedValue(null);
await expect(runAgentStart("agent-nonexistent")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("agent-nonexistent not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should report when agent is already active", async () => {
mockGetAgent.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already running"));
});
it("should report when agent is already running", async () => {
mockGetAgent.mockResolvedValue(makeAgent("running"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("already running"));
});
});

View File

@@ -0,0 +1,101 @@
import { AgentStore, AGENT_VALID_TRANSITIONS } from "@fusion/core";
import type { AgentState } from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Get the project path for agent operations.
* Falls back to process.cwd() if no project is specified.
*/
async function getProjectPath(projectName?: string): Promise<string> {
if (projectName) {
const context = await resolveProject(projectName);
return context.projectPath;
}
try {
const context = await resolveProject(undefined);
return context.projectPath;
} catch {
return process.cwd();
}
}
/**
* Create an initialized AgentStore for the given project.
*/
async function createAgentStore(projectName?: string): Promise<AgentStore> {
const projectPath = await getProjectPath(projectName);
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion" });
await agentStore.init();
return agentStore;
}
/**
* Stop (pause) a running agent.
* Transitions state from running/active to paused.
*/
export async function runAgentStop(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
process.exit(1);
}
// Already paused — nothing to do
if (agent.state === "paused") {
console.log();
console.log(` Agent ${id} is already paused`);
console.log();
return;
}
// Validate transition locally
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("paused")) {
console.error(`Cannot stop agent ${id} — current state '${agent.state}' cannot transition to 'paused'`);
process.exit(1);
}
await agentStore.updateAgentState(id, "paused");
console.log();
console.log(` ✓ Agent ${id} stopped`);
console.log();
}
/**
* Start (resume) a stopped/paused agent.
* Transitions state from paused to active.
*/
export async function runAgentStart(id: string, projectName?: string): Promise<void> {
const agentStore = await createAgentStore(projectName);
const agent = await agentStore.getAgent(id);
if (!agent) {
console.error(`Agent ${id} not found`);
process.exit(1);
}
// Already active/running — nothing to do
if (agent.state === "active" || agent.state === "running") {
console.log();
console.log(` Agent ${id} is already running (${agent.state})`);
console.log();
return;
}
// Validate transition locally
const validTargets = AGENT_VALID_TRANSITIONS[agent.state as AgentState];
if (!validTargets || !validTargets.includes("active")) {
console.error(`Cannot start agent ${id} — current state '${agent.state}' cannot transition to 'active'`);
process.exit(1);
}
await agentStore.updateAgentState(id, "active");
console.log();
console.log(` ✓ Agent ${id} started`);
console.log();
}