Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
397 changes: 397 additions & 0 deletions src/app/api/agents/[agentName]/next-task/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,397 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const { mocks } = vi.hoisted(() => ({
mocks: {
issueFindMany: vi.fn(),
prFixFindMany: vi.fn(),
findLeasedIssueIds: vi.fn(),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: { findMany: mocks.issueFindMany },
prFixQueueItem: { findMany: mocks.prFixFindMany },
},
asPrFixQueueClient: (client: any) => client,
}));

vi.mock("@/lib/lease", () => ({
findLeasedIssueIds: mocks.findLeasedIssueIds,
}));

import { GET } from "./route";

describe("GET /api/agents/[agentName]/next-task", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.prFixFindMany.mockResolvedValue([]);
mocks.issueFindMany.mockResolvedValue([]);
mocks.findLeasedIssueIds.mockResolvedValue([]);
});

it("returns idle when the queue is empty", async () => {
const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.type).toBe("idle");
expect(body.shouldRun).toBe(false);
expect(body.reason).toBe("No work available");
});

it("returns idle when queue is empty (not an array)", async () => {
const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(Array.isArray(body)).toBe(false);
});

it("returns one implement task for a normal issue", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-1",
number: 42,
title: "Fix login bug",
url: "https://github.com/org/repo/issues/42",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.type).toBe("implement");
expect(body.shouldRun).toBe(true);
expect(body.agentName).toBe("example-agent");
expect(body.issue.number).toBe(42);
expect(body.issue.title).toBe("Fix login bug");
expect(body.issue.repoFullName).toBe("org/repo");
expect(body.issue.url).toBe("https://github.com/org/repo/issues/42");
});

it("returns exactly one task, not an array", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-1",
number: 42,
title: "First issue",
url: "https://github.com/org/repo/issues/42",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
{
id: "issue-2",
number: 43,
title: "Second issue",
url: "https://github.com/org/repo/issues/43",
labels: ["priority/p1", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(Array.isArray(body)).toBe(false);
expect(body.type).toBe("implement");
});

it("returns followup-pr task when a PR-fix item is ahead of issue work", async () => {
mocks.prFixFindMany.mockResolvedValue([
{
id: "prfix-1",
repo: "org/repo",
pr: 12,
issue: 67,
branch: "fix/issue-67",
url: "https://github.com/org/repo/pull/12",
title: "Fix issue 67",
lane: "NORMAL",
status: "QUEUED",
reason: "review changes requested",
feedback: ["please update tests"],
evidenceKeys: ["review:1"],
author: "itsmiso-ai",
queuedAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
},
]);
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-1",
number: 99,
title: "Regular issue",
url: "https://github.com/org/repo/issues/99",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.type).toBe("followup-pr");
expect(body.shouldRun).toBe(true);
expect(body.agentName).toBe("example-agent");
expect(body.pullRequest.repoFullName).toBe("org/repo");
expect(body.pullRequest.number).toBe(12);
expect(body.pullRequest.url).toBe("https://github.com/org/repo/pull/12");
expect(Array.isArray(body.reasons)).toBe(true);
});

it("includes linked issue context when PR-fix has an issue number", async () => {
mocks.prFixFindMany.mockResolvedValue([
{
id: "prfix-1",
repo: "org/repo",
pr: 12,
issue: 67,
branch: "fix/issue-67",
url: "https://github.com/org/repo/pull/12",
title: "Fix issue 67",
lane: "NORMAL",
status: "QUEUED",
reason: "review changes requested",
feedback: ["please update tests"],
evidenceKeys: ["review:1"],
author: "itsmiso-ai",
queuedAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("followup-pr");
expect(body.issue.repoFullName).toBe("org/repo");
expect(body.issue.number).toBe(67);
});

it("includes both reason and feedback in reasons", async () => {
mocks.prFixFindMany.mockResolvedValue([
{
id: "prfix-1",
repo: "org/repo",
pr: 12,
issue: null,
branch: "fix/something",
url: "https://github.com/org/repo/pull/12",
title: "Fix something",
lane: "NORMAL",
status: "QUEUED",
reason: "CI failure on main",
feedback: ["update tests", "fix lint"],
evidenceKeys: ["ci:1"],
author: "bot",
queuedAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("followup-pr");
expect(body.reasons).toContain("CI failure on main");
expect(body.reasons).toContain("update tests");
expect(body.reasons).toContain("fix lint");
});

it("preserves lane filtering", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-normal",
number: 10,
title: "Normal issue",
url: "https://github.com/org/repo/issues/10",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
{
id: "issue-escalated",
number: 20,
title: "Escalated issue",
url: "https://github.com/org/repo/issues/20",
labels: ["priority/p0", "status/ready"],
currentLane: "escalated",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("implement");
expect(body.issue.number).toBe(10);
});

it("passes through includeClaimed behavior", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-claimed-other",
number: 30,
title: "Claimed by other agent",
url: "https://github.com/org/repo/issues/30",
labels: ["agent/other-agent", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("idle");
});

it("includes claimed issues when includeClaimed=true", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-claimed-other",
number: 30,
title: "Claimed by other agent",
url: "https://github.com/org/repo/issues/30",
labels: ["agent/other-agent", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request(
"http://localhost/api/agents/example-agent/next-task?lane=normal&includeClaimed=true",
),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("implement");
expect(body.issue.number).toBe(30);
});

it("does not require harness-specific fields", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-1",
number: 42,
title: "Test issue",
url: "https://github.com/org/repo/issues/42",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect("harness" in body).toBe(false);
expect("workflowRepo" in body).toBe(false);
});

it("followup-pr task uses reason when feedback is empty", async () => {
mocks.prFixFindMany.mockResolvedValue([
{
id: "prfix-1",
repo: "org/repo",
pr: 12,
issue: null,
branch: "fix/something",
url: "https://github.com/org/repo/pull/12",
title: "Fix something",
lane: "NORMAL",
status: "QUEUED",
reason: "CI failure on main",
feedback: [],
evidenceKeys: ["ci:1"],
author: "bot",
queuedAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
},
]);

const res = await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

const body = await res.json();
expect(body.type).toBe("followup-pr");
expect(body.reasons).toEqual(["CI failure on main"]);
});

it("does not mutate issue or claim state", async () => {
mocks.issueFindMany.mockResolvedValue([
{
id: "issue-1",
number: 42,
title: "Test issue",
url: "https://github.com/org/repo/issues/42",
labels: ["priority/p0", "status/ready"],
currentLane: "normal",
decomposed: false,
repository: { fullName: "org/repo" },
},
]);

await GET(
new Request("http://localhost/api/agents/example-agent/next-task?lane=normal"),
{ params: Promise.resolve({ agentName: "example-agent" }) },
);

expect(mocks.issueFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ state: "open" }),
}),
);
});
});
Loading