diff --git a/src/lib/agent-task.test.ts b/src/lib/agent-task.test.ts new file mode 100644 index 0000000..1e04ba9 --- /dev/null +++ b/src/lib/agent-task.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; +import { + createIdleTask, + createImplementTask, + createFollowupPrTask, + createGroomTask, +} from "./agent-task"; + +describe("createIdleTask", () => { + it("has shouldRun false", () => { + const task = createIdleTask("No work available"); + expect(task.shouldRun).toBe(false); + }); + + it("has type idle", () => { + const task = createIdleTask("No work available"); + expect(task.type).toBe("idle"); + }); + + it("preserves reason", () => { + const task = createIdleTask("All issues are backlog"); + expect(task.reason).toBe("All issues are backlog"); + }); + + it("does not contain agentName or issue fields", () => { + const task = createIdleTask("idle"); + expect("agentName" in task).toBe(false); + expect("issue" in task).toBe(false); + expect("instructions" in task).toBe(false); + expect("forbiddenActions" in task).toBe(false); + }); +}); + +describe("createImplementTask", () => { + const baseIssue = { + repoFullName: "misospace/dispatch", + number: 393, + title: "Add AgentTask contract", + url: "https://github.com/misospace/dispatch/issues/393", + }; + + it("has shouldRun true", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.shouldRun).toBe(true); + }); + + it("has type implement", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.type).toBe("implement"); + }); + + it("includes agentName", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.agentName).toBe("alpha"); + }); + + it("includes lane when provided", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, lane: "escalated" }); + expect(task.lane).toBe("escalated"); + }); + + it("omits lane when not provided", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.lane).toBeUndefined(); + }); + + it("includes issue fields", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.issue.repoFullName).toBe("misospace/dispatch"); + expect(task.issue.number).toBe(393); + expect(task.issue.title).toBe("Add AgentTask contract"); + expect(task.issue.url).toBe("https://github.com/misospace/dispatch/issues/393"); + }); + + it("includes stopAfter", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.stopAfter).toContain("PR"); + }); + + it("includes forbiddenActions by default", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.forbiddenActions).toContain("Merging any pull request"); + expect(task.forbiddenActions).toContain("Grooming unrelated issues"); + expect(task.forbiddenActions).toContain("Claiming another issue while this one is open"); + }); + + it("includes default instructions", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect(task.instructions).toContain("Claim or work the assigned issue"); + expect(task.instructions).toContain("Open or update exactly one PR"); + }); + + it("preserves custom instructions", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, instructions: "Custom instruction" }); + expect(task.instructions).toBe("Custom instruction"); + }); + + it("preserves custom stopAfter", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, stopAfter: "When done" }); + expect(task.stopAfter).toBe("When done"); + }); + + it("preserves custom forbiddenActions", () => { + const custom = ["No merging"]; + const task = createImplementTask({ agentName: "alpha", issue: baseIssue, forbiddenActions: custom }); + expect(task.forbiddenActions).toEqual(custom); + }); + + it("does not require harness-specific fields", () => { + const task = createImplementTask({ agentName: "alpha", issue: baseIssue }); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("createFollowupPrTask", () => { + const baseInput = { + agentName: "beta", + lane: "normal", + pullRequest: { + repoFullName: "misospace/dispatch", + number: 456, + url: "https://github.com/misospace/dispatch/pull/456", + }, + reasons: ["Review feedback", "CI failure"], + }; + + it("has shouldRun true", () => { + const task = createFollowupPrTask(baseInput); + expect(task.shouldRun).toBe(true); + }); + + it("has type followup-pr", () => { + const task = createFollowupPrTask(baseInput); + expect(task.type).toBe("followup-pr"); + }); + + it("includes pullRequest fields", () => { + const task = createFollowupPrTask(baseInput); + expect(task.pullRequest.repoFullName).toBe("misospace/dispatch"); + expect(task.pullRequest.number).toBe(456); + expect(task.pullRequest.url).toBe("https://github.com/misospace/dispatch/pull/456"); + }); + + it("includes reasons", () => { + const task = createFollowupPrTask(baseInput); + expect(task.reasons).toContain("Review feedback"); + expect(task.reasons).toContain("CI failure"); + }); + + it("preserves optional issue when provided", () => { + const task = createFollowupPrTask({ + ...baseInput, + issue: { + repoFullName: "misospace/dispatch", + number: 393, + title: "Related issue", + url: "https://github.com/misospace/dispatch/issues/393", + }, + }); + expect(task.issue?.number).toBe(393); + }); + + it("omits issue when not provided", () => { + const task = createFollowupPrTask(baseInput); + expect(task.issue).toBeUndefined(); + }); + + it("includes default instructions about fixing existing PR", () => { + const task = createFollowupPrTask(baseInput); + expect(task.instructions).toContain("Fix the existing pull request"); + }); + + it("includes forbiddenActions by default", () => { + const task = createFollowupPrTask(baseInput); + expect(task.forbiddenActions).toContain("Merging any pull request"); + expect(task.forbiddenActions).toContain("Opening a new pull request"); + }); + + it("preserves custom instructions", () => { + const task = createFollowupPrTask({ ...baseInput, instructions: "Fix lint errors" }); + expect(task.instructions).toBe("Fix lint errors"); + }); + + it("preserves repo and PR fields from input", () => { + const input = { + agentName: "gamma", + pullRequest: { + repoFullName: "other/repo", + number: 99, + }, + reasons: ["Trivial fix"], + }; + const task = createFollowupPrTask(input); + expect(task.pullRequest.repoFullName).toBe("other/repo"); + expect(task.pullRequest.number).toBe(99); + expect(task.reasons).toEqual(["Trivial fix"]); + }); + + it("does not require harness-specific fields", () => { + const task = createFollowupPrTask(baseInput); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("createGroomTask", () => { + const baseInput = { + agentName: "gamma", + }; + + it("has shouldRun true", () => { + const task = createGroomTask(baseInput); + expect(task.shouldRun).toBe(true); + }); + + it("has type groom", () => { + const task = createGroomTask(baseInput); + expect(task.type).toBe("groom"); + }); + + it("includes agentName", () => { + const task = createGroomTask(baseInput); + expect(task.agentName).toBe("gamma"); + }); + + it("includes default groom-specific instructions", () => { + const task = createGroomTask(baseInput); + expect(task.instructions).toContain("Enrich the issue"); + expect(task.instructions).toContain("labels"); + expect(task.instructions).toContain("lane"); + }); + + it("includes default forbidden actions for grooming", () => { + const task = createGroomTask(baseInput); + expect(task.forbiddenActions).toContain("Writing implementation code"); + expect(task.forbiddenActions).toContain("Opening pull requests"); + expect(task.forbiddenActions).not.toContain("Merging any pull request"); + }); + + it("preserves optional issue when provided", () => { + const task = createGroomTask({ + ...baseInput, + issue: { + repoFullName: "misospace/dispatch", + number: 100, + title: "Needs grooming", + url: "https://github.com/misospace/dispatch/issues/100", + }, + }); + expect(task.issue?.number).toBe(100); + expect(task.issue?.title).toBe("Needs grooming"); + }); + + it("omits issue when not provided", () => { + const task = createGroomTask(baseInput); + expect(task.issue).toBeUndefined(); + }); + + it("preserves custom instructions", () => { + const task = createGroomTask({ ...baseInput, instructions: "Custom grooming" }); + expect(task.instructions).toBe("Custom grooming"); + }); + + it("preserves custom forbiddenActions", () => { + const custom = ["No code changes"]; + const task = createGroomTask({ ...baseInput, forbiddenActions: custom }); + expect(task.forbiddenActions).toEqual(custom); + }); + + it("does not require harness-specific fields", () => { + const task = createGroomTask(baseInput); + expect("harness" in task).toBe(false); + expect("workflowRepo" in task).toBe(false); + }); +}); + +describe("AgentTask discriminated union", () => { + it("idle task is distinguishable by type", () => { + const task = createIdleTask("no work"); + if (task.type !== "idle") throw new Error("should be idle"); + expect(task.reason).toBe("no work"); + }); + + it("implement task is distinguishable by type", () => { + const task = createImplementTask({ + agentName: "a", + issue: { repoFullName: "r", number: 1, title: "t", url: "u" }, + }); + if (task.type !== "implement") throw new Error("should be implement"); + expect(task.issue.number).toBe(1); + }); + + it("followup-pr task is distinguishable by type", () => { + const task = createFollowupPrTask({ + agentName: "a", + pullRequest: { repoFullName: "r", number: 2 }, + reasons: ["r"], + }); + if (task.type !== "followup-pr") throw new Error("should be followup-pr"); + expect(task.pullRequest.number).toBe(2); + }); + + it("groom task is distinguishable by type", () => { + const task = createGroomTask({ agentName: "a" }); + if (task.type !== "groom") throw new Error("should be groom"); + expect(task.agentName).toBe("a"); + }); +}); + +describe("forbiddenActions mutation isolation", () => { + it("mutating one implement task's forbiddenActions does not affect another", () => { + const issue = { repoFullName: "r", number: 1, title: "t", url: "u" }; + const taskA = createImplementTask({ agentName: "a", issue }); + const taskB = createImplementTask({ agentName: "b", issue }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating one followup-pr task's forbiddenActions does not affect another", () => { + const pr = { repoFullName: "r", number: 1 }; + const taskA = createFollowupPrTask({ agentName: "a", pullRequest: pr, reasons: ["r"] }); + const taskB = createFollowupPrTask({ agentName: "b", pullRequest: pr, reasons: ["r"] }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating one groom task's forbiddenActions does not affect another", () => { + const taskA = createGroomTask({ agentName: "a" }); + const taskB = createGroomTask({ agentName: "b" }); + + taskA.forbiddenActions.push("Injected action"); + + expect(taskA.forbiddenActions).toContain("Injected action"); + expect(taskB.forbiddenActions).not.toContain("Injected action"); + }); + + it("mutating caller's input array does not affect created task", () => { + const custom = ["Custom forbidden"]; + const issue = { repoFullName: "r", number: 1, title: "t", url: "u" }; + const task = createImplementTask({ agentName: "a", issue, forbiddenActions: custom }); + + custom.push("Added after creation"); + + expect(task.forbiddenActions).not.toContain("Added after creation"); + expect(task.forbiddenActions).toEqual(["Custom forbidden"]); + }); +}); diff --git a/src/lib/agent-task.ts b/src/lib/agent-task.ts new file mode 100644 index 0000000..6f3d161 --- /dev/null +++ b/src/lib/agent-task.ts @@ -0,0 +1,173 @@ +export interface IssueRef { + repoFullName: string; + number: number; + title: string; + url: string; +} + +export interface OptionalIssueRef { + repoFullName: string; + number: number; + title?: string; + url?: string; +} + +export interface PullRequestRef { + repoFullName: string; + number: number; + url?: string; +} + +export interface IdleTask { + type: "idle"; + shouldRun: false; + reason: string; +} + +export interface ImplementTask { + type: "implement"; + shouldRun: true; + agentName: string; + lane?: string; + issue: IssueRef; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export interface FollowupPrTask { + type: "followup-pr"; + shouldRun: true; + agentName: string; + lane?: string; + issue?: OptionalIssueRef; + pullRequest: PullRequestRef; + reasons: string[]; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export interface GroomTask { + type: "groom"; + shouldRun: true; + agentName: string; + issue?: IssueRef; + instructions: string; + stopAfter: string; + forbiddenActions: string[]; +} + +export type AgentTask = IdleTask | ImplementTask | FollowupPrTask | GroomTask; + +const IMPLEMENT_INSTRUCTIONS = + "Claim or work the assigned issue. Open or update exactly one PR, then stop. Do not merge, groom unrelated issues, or claim another issue."; + +const IMPLEMENT_STOP_AFTER = + "One PR is open or updated for the issue. Push remaining work to a follow-up issue."; + +const FOLLOWUP_PR_INSTRUCTIONS = + "Fix the existing pull request. Update it with the requested changes, then stop. Do not merge, open new PRs, or claim another issue."; + +const FOLLOWUP_PR_STOP_AFTER = + "The queued PR has been updated with the requested fixes. Push remaining work to a follow-up."; + +const GROOM_INSTRUCTIONS = + "Enrich the issue with labels, lane classification, and status assignment. Close completed work. Do not implement or open PRs."; + +const GROOM_STOP_AFTER = + "The issue has been enriched with labels, lane, and status. Close if completed."; + +const IMPLEMENT_FORBIDDEN = [ + "Merging any pull request", + "Grooming unrelated issues", + "Claiming another issue while this one is open", +]; + +const FOLLOWUP_PR_FORBIDDEN = [ + "Merging any pull request", + "Opening a new pull request", + "Claiming another issue while this PR is queued", +]; + +const GROOM_FORBIDDEN = [ + "Writing implementation code", + "Opening pull requests", + "Modifying production configuration", +]; + +export function createIdleTask(reason: string): IdleTask { + return { + type: "idle", + shouldRun: false, + reason, + }; +} + +export interface ImplementTaskInput { + agentName: string; + lane?: string; + issue: IssueRef; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createImplementTask(input: ImplementTaskInput): ImplementTask { + return { + type: "implement", + shouldRun: true, + agentName: input.agentName, + lane: input.lane, + issue: input.issue, + instructions: input.instructions ?? IMPLEMENT_INSTRUCTIONS, + stopAfter: input.stopAfter ?? IMPLEMENT_STOP_AFTER, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...IMPLEMENT_FORBIDDEN], + }; +} + +export interface FollowupPrTaskInput { + agentName: string; + lane?: string; + issue?: OptionalIssueRef; + pullRequest: PullRequestRef; + reasons: string[]; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createFollowupPrTask(input: FollowupPrTaskInput): FollowupPrTask { + return { + type: "followup-pr", + shouldRun: true, + agentName: input.agentName, + lane: input.lane, + issue: input.issue, + pullRequest: input.pullRequest, + reasons: input.reasons, + instructions: input.instructions ?? FOLLOWUP_PR_INSTRUCTIONS, + stopAfter: input.stopAfter ?? FOLLOWUP_PR_STOP_AFTER, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...FOLLOWUP_PR_FORBIDDEN], + }; +} + +export interface GroomTaskInput { + agentName: string; + issue?: IssueRef; + instructions?: string; + stopAfter?: string; + forbiddenActions?: string[]; +} + +export function createGroomTask(input: GroomTaskInput): GroomTask { + return { + type: "groom", + shouldRun: true, + agentName: input.agentName, + issue: input.issue, + instructions: input.instructions ?? GROOM_INSTRUCTIONS, + stopAfter: input.stopAfter ?? GROOM_STOP_AFTER, + forbiddenActions: input.forbiddenActions ? [...input.forbiddenActions] : [...GROOM_FORBIDDEN], + }; +}