252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { buildAgentMentionHref } from "@paperclipai/shared";
|
|
import {
|
|
bodyHasAgentMention,
|
|
classifyAssigneeHandoff,
|
|
computePauseAffectsSummary,
|
|
computeComposerHandoffPreview,
|
|
describeReassignInterrupt,
|
|
extractAgentMentionIds,
|
|
findPlainAgentNameCandidate,
|
|
isOperatorInterruptedRun,
|
|
resolveRunStatusPresentation,
|
|
type PauseAffectsIssueLike,
|
|
} from "./interrupt-handoff";
|
|
|
|
const QA_ID = "agent-qa-1111";
|
|
const QA_HREF = buildAgentMentionHref(QA_ID, null);
|
|
const qaMention = `[@QA](${QA_HREF})`;
|
|
|
|
describe("isOperatorInterruptedRun", () => {
|
|
it("detects operator interrupts via errorCode", () => {
|
|
expect(isOperatorInterruptedRun(null, "operator_interrupted")).toBe(true);
|
|
});
|
|
|
|
it("detects operator interrupts via resultJson flag", () => {
|
|
expect(isOperatorInterruptedRun({ operatorInterrupted: true })).toBe(true);
|
|
expect(isOperatorInterruptedRun({ interruptionSource: "issue_comment_interrupt" })).toBe(true);
|
|
});
|
|
|
|
it("does not flag ordinary cancels or failures", () => {
|
|
expect(isOperatorInterruptedRun({ stopReason: "cancelled" }, "cancelled")).toBe(false);
|
|
expect(isOperatorInterruptedRun(null, "claude_exit_143")).toBe(false);
|
|
expect(isOperatorInterruptedRun(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveRunStatusPresentation", () => {
|
|
it("renders an operator-interrupted cancel as amber 'interrupted'", () => {
|
|
const p = resolveRunStatusPresentation("cancelled", { operatorInterrupted: true });
|
|
expect(p.label).toBe("interrupted");
|
|
expect(p.className).toContain("amber");
|
|
expect(p.srHint).toMatch(/board comment/);
|
|
});
|
|
|
|
it("renders a plain cancel as muted 'cancelled'", () => {
|
|
const p = resolveRunStatusPresentation("cancelled");
|
|
expect(p.label).toBe("cancelled");
|
|
expect(p.className).toContain("muted");
|
|
expect(p.srHint).toBeNull();
|
|
});
|
|
|
|
it("leaves failed/succeeded untouched", () => {
|
|
expect(resolveRunStatusPresentation("failed").className).toContain("red");
|
|
expect(resolveRunStatusPresentation("timed_out").label).toBe("timed out");
|
|
});
|
|
});
|
|
|
|
describe("structured mention vs plain text", () => {
|
|
it("extracts agent ids only from structured agent:// links", () => {
|
|
expect(extractAgentMentionIds(`hey ${qaMention} please look`)).toEqual([QA_ID]);
|
|
expect(bodyHasAgentMention(`hey ${qaMention}`)).toBe(true);
|
|
});
|
|
|
|
it("treats a plain QA name as NOT a mention", () => {
|
|
expect(bodyHasAgentMention("QA you take the screenshot")).toBe(false);
|
|
expect(extractAgentMentionIds("ask QA to confirm")).toEqual([]);
|
|
});
|
|
|
|
it("flags a plain agent-name candidate for the coach", () => {
|
|
const candidate = findPlainAgentNameCandidate("ask QA to confirm", [
|
|
{ agentId: QA_ID, name: "QA" },
|
|
]);
|
|
expect(candidate?.agentId).toBe(QA_ID);
|
|
expect(candidate?.matchedText).toBe("QA");
|
|
});
|
|
|
|
it("does not flag a name that is already a structured chip", () => {
|
|
const candidate = findPlainAgentNameCandidate(`hey ${qaMention} thanks`, [
|
|
{ agentId: QA_ID, name: "QA" },
|
|
]);
|
|
expect(candidate).toBeNull();
|
|
});
|
|
|
|
it("matches by role as well as display name", () => {
|
|
const candidate = findPlainAgentNameCandidate("can the reviewer check this", [
|
|
{ agentId: "agent-x", name: "Casey", role: "reviewer" },
|
|
]);
|
|
expect(candidate?.matchedText).toBe("reviewer");
|
|
});
|
|
});
|
|
|
|
describe("computeComposerHandoffPreview", () => {
|
|
const base = {
|
|
currentAssigneeValue: "agent:claude-1",
|
|
hasActiveRun: true,
|
|
bodyHasAgentMention: false,
|
|
plainNameCandidate: null,
|
|
};
|
|
|
|
it("A. reassign to agent with active run = interrupt + handoff", () => {
|
|
const p = computeComposerHandoffPreview({ ...base, reassignTarget: `agent:${QA_ID}` });
|
|
expect(p.kind).toBe("interrupt_handoff_agent");
|
|
expect(p.chip).toEqual({ kind: "agent", id: QA_ID });
|
|
expect(p.tone).toBe("neutral");
|
|
});
|
|
|
|
it("reassign to agent with no active run = wake", () => {
|
|
const p = computeComposerHandoffPreview({
|
|
...base,
|
|
hasActiveRun: false,
|
|
reassignTarget: `agent:${QA_ID}`,
|
|
});
|
|
expect(p.kind).toBe("wake_agent");
|
|
});
|
|
|
|
it("B. reassign to user = handoff, no agent wake", () => {
|
|
const p = computeComposerHandoffPreview({ ...base, reassignTarget: "user:user-board" });
|
|
expect(p.kind).toBe("user_handoff");
|
|
expect(p.chip).toEqual({ kind: "user", id: "user-board" });
|
|
expect(p.suffix).toMatch(/no agent/i);
|
|
});
|
|
|
|
it("clearing the assignee reads as no agent wake", () => {
|
|
const p = computeComposerHandoffPreview({ ...base, reassignTarget: "__none__" });
|
|
expect(p.kind).toBe("clear_assignee");
|
|
expect(p.text).toMatch(/no agent/i);
|
|
});
|
|
|
|
it("no reassign + structured mention = notify agent", () => {
|
|
const p = computeComposerHandoffPreview({
|
|
...base,
|
|
reassignTarget: base.currentAssigneeValue,
|
|
bodyHasAgentMention: true,
|
|
mentionedAgentId: QA_ID,
|
|
});
|
|
expect(p.kind).toBe("notify_agent");
|
|
expect(p.chip).toEqual({ kind: "agent", id: QA_ID });
|
|
});
|
|
|
|
it("C. no reassign + plain name only = amber warning, no wake", () => {
|
|
const p = computeComposerHandoffPreview({
|
|
...base,
|
|
reassignTarget: base.currentAssigneeValue,
|
|
plainNameCandidate: { agentId: QA_ID, matchedText: "QA" },
|
|
});
|
|
expect(p.kind).toBe("plain_text_only");
|
|
expect(p.tone).toBe("warn");
|
|
expect(p.chip).toBeUndefined();
|
|
});
|
|
|
|
it("nothing notable = hidden preview", () => {
|
|
const p = computeComposerHandoffPreview({
|
|
...base,
|
|
reassignTarget: base.currentAssigneeValue,
|
|
});
|
|
expect(p.kind).toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("classifyAssigneeHandoff", () => {
|
|
it("A. to agent = queued wake", () => {
|
|
const info = classifyAssigneeHandoff({ agentId: QA_ID, userId: null }, { agentName: "QA" });
|
|
expect(info.kind).toBe("agent_wake");
|
|
expect(info.wakeText).toMatch(/queued for QA/);
|
|
});
|
|
|
|
it("agent wake notes attached interrupted run when present", () => {
|
|
const info = classifyAssigneeHandoff(
|
|
{ agentId: QA_ID, userId: null },
|
|
{ agentName: "QA", interruptedRunAttached: true },
|
|
);
|
|
expect(info.wakeText).toMatch(/interrupted run attached/);
|
|
});
|
|
|
|
it("B. to user = no wake, board handoff", () => {
|
|
const info = classifyAssigneeHandoff({ agentId: null, userId: "user-board" });
|
|
expect(info.kind).toBe("user_handoff");
|
|
expect(info.wakeText).toMatch(/not created/);
|
|
});
|
|
|
|
it("C. unassigned = no wake, needs explicit agent", () => {
|
|
const info = classifyAssigneeHandoff({ agentId: null, userId: null });
|
|
expect(info.kind).toBe("unassigned");
|
|
expect(info.wakeText).toMatch(/no agent selected/);
|
|
});
|
|
});
|
|
|
|
describe("describeReassignInterrupt", () => {
|
|
it("names the running agent in the banner and confirm", () => {
|
|
const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" });
|
|
expect(copy.banner).toBe("ClaudeCoder is running — changing the responsible will interrupt this run.");
|
|
expect(copy.confirmAction).toBe("Interrupt & assign");
|
|
expect(copy.cancelAction).toBe("Cancel");
|
|
});
|
|
|
|
it("falls back to a generic subject when no name is known", () => {
|
|
expect(describeReassignInterrupt().banner).toMatch(/^An agent is running/);
|
|
expect(describeReassignInterrupt({ runningAgentName: " " }).banner).toMatch(/^An agent is running/);
|
|
});
|
|
});
|
|
|
|
describe("computePauseAffectsSummary", () => {
|
|
const issue = (over: Partial<PauseAffectsIssueLike>): PauseAffectsIssueLike => ({
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
activeRun: null,
|
|
...over,
|
|
});
|
|
const bucket = (summary: ReturnType<typeof computePauseAffectsSummary>, key: string) =>
|
|
summary.buckets.find((b) => b.key === key)!;
|
|
|
|
it("partitions affected issues into disjoint buckets", () => {
|
|
const summary = computePauseAffectsSummary([
|
|
issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }),
|
|
issue({ assigneeAgentId: "a2", activeRun: { status: "queued" } }),
|
|
issue({ assigneeAgentId: "a3" }),
|
|
issue({ assigneeUserId: "u1" }),
|
|
issue({}),
|
|
]);
|
|
expect(bucket(summary, "live_runs").count).toBe(1);
|
|
expect(bucket(summary, "queued_wakes").count).toBe(1);
|
|
expect(bucket(summary, "agent_owned").count).toBe(1);
|
|
expect(bucket(summary, "human_owned").count).toBe(1);
|
|
expect(bucket(summary, "static").count).toBe(1);
|
|
expect(summary.affectedIssueCount).toBe(5);
|
|
expect(summary.nothingLive).toBe(false);
|
|
});
|
|
|
|
it("ignores skipped issues", () => {
|
|
const summary = computePauseAffectsSummary([
|
|
issue({ assigneeAgentId: "a1", activeRun: { status: "running" }, skipped: true }),
|
|
issue({ assigneeUserId: "u1" }),
|
|
]);
|
|
expect(summary.affectedIssueCount).toBe(1);
|
|
expect(bucket(summary, "live_runs").count).toBe(0);
|
|
expect(bucket(summary, "human_owned").count).toBe(1);
|
|
});
|
|
|
|
it("flags nothingLive when no run is live or queued", () => {
|
|
const summary = computePauseAffectsSummary([issue({ assigneeUserId: "u1" }), issue({})]);
|
|
expect(summary.nothingLive).toBe(true);
|
|
});
|
|
|
|
it("an active run wins over the issue's owner bucket", () => {
|
|
const summary = computePauseAffectsSummary([
|
|
issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }),
|
|
]);
|
|
expect(bucket(summary, "live_runs").count).toBe(1);
|
|
expect(bucket(summary, "agent_owned").count).toBe(0);
|
|
});
|
|
});
|