288 lines
7.9 KiB
TypeScript
288 lines
7.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
|
|
import { agentsApi, subscribeAgentStream } from "./agents";
|
|
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const sampleAgent: AgentInfo = {
|
|
story_id: "42_story_test",
|
|
agent_name: "coder",
|
|
status: "running",
|
|
session_id: null,
|
|
worktree_path: null,
|
|
base_branch: null,
|
|
log_session_id: null,
|
|
};
|
|
|
|
const sampleConfig: AgentConfigInfo = {
|
|
name: "coder",
|
|
role: "engineer",
|
|
stage: "coder",
|
|
model: "claude-sonnet-4-6",
|
|
allowed_tools: null,
|
|
max_turns: null,
|
|
max_budget_usd: null,
|
|
};
|
|
|
|
// ── agentsApi ────────────────────────────────────────────────────────────────
|
|
|
|
describe("agentsApi", () => {
|
|
describe("startAgent", () => {
|
|
it("dispatches agents.start RPC with story_id and returns AgentInfo", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("agents.start", sampleAgent);
|
|
|
|
const result = await agentsApi.startAgent("42_story_test");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "agents.start",
|
|
params: { story_id: "42_story_test", agent_name: undefined },
|
|
},
|
|
]);
|
|
expect(result).toEqual(sampleAgent);
|
|
});
|
|
|
|
it("sends optional agent_name in params", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("agents.start", sampleAgent);
|
|
|
|
await agentsApi.startAgent("42_story_test", "coder");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "agents.start",
|
|
params: { story_id: "42_story_test", agent_name: "coder" },
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("stopAgent", () => {
|
|
it("dispatches agents.stop RPC with story_id and agent_name", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("agents.stop", true);
|
|
|
|
const result = await agentsApi.stopAgent("42_story_test", "coder");
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{
|
|
method: "agents.stop",
|
|
params: { story_id: "42_story_test", agent_name: "coder" },
|
|
},
|
|
]);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("getAgentConfig", () => {
|
|
it("dispatches an agent_config.list RPC and returns the config list", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("agent_config.list", [sampleConfig]);
|
|
|
|
const result = await agentsApi.getAgentConfig();
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{ method: "agent_config.list", params: {} },
|
|
]);
|
|
expect(result).toEqual([sampleConfig]);
|
|
});
|
|
|
|
it("surfaces RPC errors visibly", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respondError("agent_config.list", "config not found", "NOT_FOUND");
|
|
|
|
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
|
"config not found",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("reloadConfig", () => {
|
|
it("dispatches agent_config.list RPC and returns the config list", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respond("agent_config.list", [sampleConfig]);
|
|
|
|
const result = await agentsApi.reloadConfig();
|
|
|
|
expect(rpc.calls).toEqual([
|
|
{ method: "agent_config.list", params: {} },
|
|
]);
|
|
expect(result).toEqual([sampleConfig]);
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("surfaces RPC errors from startAgent", async () => {
|
|
const rpc = installRpcMock();
|
|
rpc.respondError("agents.start", "story not found", "NOT_FOUND");
|
|
|
|
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
|
|
"story not found",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── subscribeAgentStream ─────────────────────────────────────────────────────
|
|
|
|
interface MockESInstance {
|
|
url: string;
|
|
onmessage: ((e: { data: string }) => void) | null;
|
|
onerror: ((e: Event) => void) | null;
|
|
close: ReturnType<typeof vi.fn>;
|
|
simulateMessage: (data: unknown) => void;
|
|
simulateError: (e: Event) => void;
|
|
}
|
|
|
|
function makeMockEventSource() {
|
|
const instances: MockESInstance[] = [];
|
|
|
|
class MockEventSource {
|
|
onmessage: ((e: { data: string }) => void) | null = null;
|
|
onerror: ((e: Event) => void) | null = null;
|
|
close = vi.fn();
|
|
|
|
constructor(public url: string) {
|
|
instances.push(this as unknown as MockESInstance);
|
|
}
|
|
|
|
simulateMessage(data: unknown) {
|
|
this.onmessage?.({ data: JSON.stringify(data) });
|
|
}
|
|
|
|
simulateError(e: Event) {
|
|
this.onerror?.(e);
|
|
}
|
|
}
|
|
|
|
return { MockEventSource, instances };
|
|
}
|
|
|
|
describe("subscribeAgentStream", () => {
|
|
let instances: MockESInstance[];
|
|
|
|
beforeEach(() => {
|
|
const { MockEventSource, instances: inst } = makeMockEventSource();
|
|
instances = inst;
|
|
vi.stubGlobal("EventSource", MockEventSource);
|
|
});
|
|
|
|
it("creates an EventSource with encoded story and agent in URL", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
expect(instances).toHaveLength(1);
|
|
expect(instances[0].url).toContain(
|
|
`/agents/${encodeURIComponent("42_story_test")}/${encodeURIComponent("coder")}/stream`,
|
|
);
|
|
});
|
|
|
|
it("calls onEvent when a message is received", () => {
|
|
const onEvent = vi.fn();
|
|
subscribeAgentStream("42_story_test", "coder", onEvent);
|
|
|
|
const event: AgentEvent = { type: "output", text: "hello" };
|
|
instances[0].simulateMessage(event);
|
|
|
|
expect(onEvent).toHaveBeenCalledWith(event);
|
|
});
|
|
|
|
it("closes EventSource on 'done' type event", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
instances[0].simulateMessage({ type: "done" });
|
|
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes EventSource on 'error' type event", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
instances[0].simulateMessage({
|
|
type: "error",
|
|
message: "something failed",
|
|
});
|
|
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes EventSource on status=stopped event", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
instances[0].simulateMessage({ type: "status", status: "stopped" });
|
|
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not close on status=running event", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
instances[0].simulateMessage({ type: "status", status: "running" });
|
|
|
|
expect(instances[0].close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not close on 'output' event", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
instances[0].simulateMessage({ type: "output", text: "building..." });
|
|
|
|
expect(instances[0].close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls onError and closes on EventSource onerror", () => {
|
|
const onError = vi.fn();
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn(), onError);
|
|
|
|
const err = new Event("error");
|
|
instances[0].simulateError(err);
|
|
|
|
expect(onError).toHaveBeenCalledWith(err);
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes EventSource when onError is not provided", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
const err = new Event("error");
|
|
instances[0].simulateError(err);
|
|
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes EventSource when cleanup function is called", () => {
|
|
const cleanup = subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
cleanup();
|
|
|
|
expect(instances[0].close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles malformed JSON without throwing", () => {
|
|
subscribeAgentStream("42_story_test", "coder", vi.fn());
|
|
|
|
expect(() => {
|
|
instances[0].onmessage?.({ data: "{ not valid json" });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("delivers multiple events before a terminal event", () => {
|
|
const onEvent = vi.fn();
|
|
subscribeAgentStream("42_story_test", "coder", onEvent);
|
|
|
|
instances[0].simulateMessage({ type: "output", text: "line 1" });
|
|
instances[0].simulateMessage({ type: "output", text: "line 2" });
|
|
instances[0].simulateMessage({ type: "done" });
|
|
|
|
expect(onEvent).toHaveBeenCalledTimes(3);
|
|
expect(instances[0].close).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|