[codex] Clarify interrupt handoffs and scoped wake semantics (#7855)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue thread is the operator surface where comments, assignee changes, pauses, resumes, and wakeups turn human intent into agent execution. > - Interrupting a live run and handing work to another assignee needs clear semantics so the product does not accidentally keep work alive, wake the wrong participant, or hide why an agent stopped. > - Comment-driven wakes also need strict boundaries so closed, blocked, and dependency-driven work only resumes when there is real actionable input. > - This pull request codifies the interrupt handoff contract, implements backend scheduling behavior, and gives the UI clearer handoff/pause language. > - The benefit is a more inspectable and predictable task lifecycle for both operators and agents. ## Linked Issues or Issue Description Paperclip issue: `PAP-10664` / `PAP-10751`. Problem: interrupting or reassigning live agent work could be ambiguous in the UI and backend. Operators needed clearer feedback about whether a handoff wakes an agent, what pause/cancel affects, and when comments should revive execution. The backend also needed stronger tests around comment wake boundaries, retry supersession, and structured agent mention dispatch. Related GitHub PR search found broad workflow-adjacent PRs #5082, #6359, and #4083, but no exact duplicate for this head branch or interrupt-handoff scope. ## What Changed - Added an interrupt handoff semantics document covering destination behavior, wake expectations, and live-run interruption states. - Implemented backend interrupt handoff behavior and comment wake/reopen handling in issue routes/services and heartbeat scheduling. - Hardened structured agent mention dispatch so mentions resolve through the intended dispatch path. - Added UI helpers and components for handoff chips, wake rows, interrupt banners, pause-affects summaries, and composer guidance. - Updated the issue properties assignee picker and issue chat/composer surfaces to make interrupt/reassign behavior clearer. - Added backend, UI utility, component, and Storybook coverage for the new behavior. - Stabilized the new UI component tests with a local `flushSync`-backed act helper matching existing repo practice in this dependency set. - Addressed Greptile feedback by threading historical run `errorCode` through issue-run data and operator-interrupted chat labels. - Addressed Greptile's cancel ordering concern by terminating/deleting in-memory heartbeat processes before cancellation status persistence, with regression coverage for DB update failure. ## Verification - `git diff --check $(git merge-base HEAD origin/master)..HEAD` - `pnpm --filter @paperclipai/ui exec vitest run src/lib/interrupt-handoff.test.ts src/lib/issue-chat-messages.test.ts src/components/IssueProperties.test.tsx src/components/interrupt-handoff/InterruptHandoffViews.test.tsx --no-file-parallelism --maxWorkers=1` — 4 files / 91 tests passed before the Greptile follow-ups. - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-tree-control-service.test.ts server/src/__tests__/issue-update-comment-wakeup-routes.test.ts server/src/__tests__/issues-service.test.ts --no-file-parallelism --maxWorkers=1` — 6 files / 191 tests passed before the Greptile follow-ups. - `pnpm --filter @paperclipai/ui exec vitest run src/lib/issue-chat-messages.test.ts --no-file-parallelism --maxWorkers=1` — 1 file / 24 tests passed after the historical `errorCode` follow-up. - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/activity-routes.test.ts --no-file-parallelism --maxWorkers=1` — 2 files / 11 tests passed after the historical `errorCode` follow-up. - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts --no-file-parallelism --maxWorkers=1` — 1 file / 52 tests passed after the cancel ordering follow-up. - Greptile is green for head `272647636287d034bab8d981eaf5305865aa0f96`; the old inline P2 is resolved/outdated. - GitHub Actions, Socket, security-review, and Greptile checks are green for head `272647636287d034bab8d981eaf5305865aa0f96`. The external `security/snyk (cryppadotta)` status was still pending at `https://app.snyk.io/org/cryppadotta/pr-checks/85b3e8f4-04e1-4f8e-9362-899c8148c23c` after a bounded wait. ## Risks - Medium: changes touch issue comments, wake scheduling, and live-run interruption semantics, so regressions could affect when agents resume or stay stopped. - Medium: UI copy and state grouping for assignee changes may need reviewer tuning after product review. - Low migration risk: no database schema migration is included. - The branch was created before the latest `origin/master` commits; reviewers should confirm CI merge-base behavior and resolve any merge conflicts if GitHub reports them. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding agent, tool use and local command execution enabled. Exact hosted model build and context window were not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Screenshot note: this PR includes Storybook coverage for the new interrupt handoff UI states rather than captured before/after browser screenshots in this PR-creation heartbeat. --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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 assignee 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { parseAgentMentionHref } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Shared logic for the "interrupt handoff" UX clarity surfaces (PAP-10669).
|
||||
*
|
||||
* The single rule every surface enforces: an *agent* appearance — agent chip,
|
||||
* "handed to <agent>" copy, a queued wake — is reserved for either (a) a durable
|
||||
* `assigneeAgentId` mutation, or (b) a structured agent mention (`agent://<id>`).
|
||||
* Plain text such as `QA` or `please get QA on this` never implies an agent wake.
|
||||
*
|
||||
* Backend interrupt semantics (see server/src/routes/issues.ts
|
||||
* `operatorInterruptCancelOptions`): an operator-triggered interrupt cancels the
|
||||
* active run with `errorCode: "operator_interrupted"` and
|
||||
* `resultJson.operatorInterrupted = true` /
|
||||
* `resultJson.interruptionSource = "issue_comment_interrupt"`.
|
||||
*/
|
||||
|
||||
// --- Run interruption ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether a run's terminal record reflects an intentional operator interrupt
|
||||
* (a board comment that cancelled the active run) rather than an unexplained
|
||||
* failure or a plain control-plane cancel.
|
||||
*/
|
||||
export function isOperatorInterruptedRun(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
errorCode?: string | null,
|
||||
): boolean {
|
||||
if (errorCode === "operator_interrupted") return true;
|
||||
if (!resultJson || typeof resultJson !== "object") return false;
|
||||
if (resultJson.operatorInterrupted === true) return true;
|
||||
return resultJson.interruptionSource === "issue_comment_interrupt";
|
||||
}
|
||||
|
||||
export function runStatusClassName(status: string): string {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "text-green-700 dark:text-green-300";
|
||||
case "failed":
|
||||
case "error":
|
||||
return "text-red-700 dark:text-red-300";
|
||||
case "timed_out":
|
||||
return "text-orange-700 dark:text-orange-300";
|
||||
case "running":
|
||||
return "text-cyan-700 dark:text-cyan-300";
|
||||
case "queued":
|
||||
case "pending":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
case "cancelled":
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunStatusPresentation {
|
||||
label: string;
|
||||
className: string;
|
||||
/** Screen-reader-only clarifier, or null. */
|
||||
srHint: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the visible run status. A board-triggered interrupt reads as
|
||||
* "interrupted" (amber, operator-intentional) instead of a muted "cancelled"
|
||||
* that looks like an adapter failure.
|
||||
*/
|
||||
export function resolveRunStatusPresentation(
|
||||
status: string,
|
||||
opts: { operatorInterrupted?: boolean } = {},
|
||||
): RunStatusPresentation {
|
||||
if (status === "cancelled" && opts.operatorInterrupted) {
|
||||
return {
|
||||
label: "interrupted",
|
||||
className: "text-amber-700 dark:text-amber-300",
|
||||
srHint: "interrupted by board comment",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: status === "timed_out" ? "timed out" : status.replace(/_/g, " "),
|
||||
className: runStatusClassName(status),
|
||||
srHint: null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Structured mention vs plain text -----------------------------------------
|
||||
|
||||
const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)]*)\)/g;
|
||||
|
||||
/** Ordered list of agent ids referenced via structured `agent://` mentions. */
|
||||
export function extractAgentMentionIds(body: string): string[] {
|
||||
const ids: string[] = [];
|
||||
if (!body) return ids;
|
||||
for (const match of body.matchAll(MARKDOWN_LINK_RE)) {
|
||||
const href = match[1] ?? "";
|
||||
const parsed = parseAgentMentionHref(href);
|
||||
if (parsed?.agentId && !ids.includes(parsed.agentId)) {
|
||||
ids.push(parsed.agentId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function bodyHasAgentMention(body: string): boolean {
|
||||
return extractAgentMentionIds(body).length > 0;
|
||||
}
|
||||
|
||||
/** Strip every markdown link so chip labels/hrefs are not mistaken for plain text. */
|
||||
function plainTextOutsideLinks(body: string): string {
|
||||
return body.replace(MARKDOWN_LINK_RE, " ");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export interface HandoffAgentMention {
|
||||
agentId: string;
|
||||
name: string;
|
||||
/** Optional role label (e.g. "QA"). */
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface PlainAgentNameCandidate {
|
||||
agentId: string;
|
||||
/** The token (agent name or role) that matched in the body. */
|
||||
matchedText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a plain-text token in `body` that names a known agent (by display name or
|
||||
* role) but is *not* a structured `agent://` mention. This is the signal the
|
||||
* composer coach uses to offer an "Insert mention" upgrade. Returns the
|
||||
* highest-confidence (longest-token) match, or null.
|
||||
*/
|
||||
export function findPlainAgentNameCandidate(
|
||||
body: string,
|
||||
mentions: readonly HandoffAgentMention[],
|
||||
): PlainAgentNameCandidate | null {
|
||||
if (!body.trim() || mentions.length === 0) return null;
|
||||
const text = plainTextOutsideLinks(body);
|
||||
let best: PlainAgentNameCandidate | null = null;
|
||||
|
||||
for (const mention of mentions) {
|
||||
const tokens = [mention.name, mention.role ?? ""].filter((t) => t && t.trim().length >= 2);
|
||||
for (const token of tokens) {
|
||||
const re = new RegExp(`(?<![\\w@/])${escapeRegExp(token)}(?![\\w/])`, "i");
|
||||
if (re.test(text)) {
|
||||
if (!best || token.length > best.matchedText.length) {
|
||||
best = { agentId: mention.agentId, matchedText: token };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// --- Composer interpretation preview ------------------------------------------
|
||||
|
||||
export type HandoffPreviewTone = "neutral" | "warn";
|
||||
|
||||
export type ComposerHandoffPreviewKind =
|
||||
| "interrupt_handoff_agent"
|
||||
| "wake_agent"
|
||||
| "user_handoff"
|
||||
| "clear_assignee"
|
||||
| "notify_agent"
|
||||
| "plain_text_only"
|
||||
| "none";
|
||||
|
||||
export interface ComposerHandoffPreview {
|
||||
kind: ComposerHandoffPreviewKind;
|
||||
tone: HandoffPreviewTone;
|
||||
/** Copy rendered before the optional chip. */
|
||||
text: string;
|
||||
/** Copy rendered after the optional chip. */
|
||||
suffix?: string;
|
||||
/** Entity to render as a mini chip, if any. */
|
||||
chip?: { kind: "agent" | "user"; id: string };
|
||||
}
|
||||
|
||||
function parseReassignValue(value: string): { kind: "agent" | "user" | "none"; id: string | null } {
|
||||
if (!value || value === "__none__") return { kind: "none", id: null };
|
||||
if (value.startsWith("agent:")) {
|
||||
const id = value.slice("agent:".length);
|
||||
return { kind: "agent", id: id || null };
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
const id = value.slice("user:".length);
|
||||
return { kind: "user", id: id || null };
|
||||
}
|
||||
return { kind: "none", id: null };
|
||||
}
|
||||
|
||||
export interface ComposerHandoffPreviewInput {
|
||||
/** Current picker value, e.g. "agent:<id>", "user:<id>", "__none__", "". */
|
||||
reassignTarget: string;
|
||||
/** The issue's current assignee value in the same encoding. */
|
||||
currentAssigneeValue: string;
|
||||
/** Whether an agent run is currently in flight on this issue. */
|
||||
hasActiveRun: boolean;
|
||||
/** Whether the comment body contains a structured agent mention. */
|
||||
bodyHasAgentMention: boolean;
|
||||
/** First agent id structurally mentioned in the body, if any. */
|
||||
mentionedAgentId?: string | null;
|
||||
/** A plain-text agent-name candidate detected in the body, if any. */
|
||||
plainNameCandidate?: PlainAgentNameCandidate | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the one-line interpretation of what submitting this comment will
|
||||
* durably do. This is the composer footer preview (design surface 1c) and the
|
||||
* core of the agent-vs-user disambiguation.
|
||||
*/
|
||||
export function computeComposerHandoffPreview(
|
||||
input: ComposerHandoffPreviewInput,
|
||||
): ComposerHandoffPreview {
|
||||
const hasReassignment = input.reassignTarget !== input.currentAssigneeValue;
|
||||
|
||||
if (hasReassignment) {
|
||||
const target = parseReassignValue(input.reassignTarget);
|
||||
if (target.kind === "agent" && target.id) {
|
||||
return input.hasActiveRun
|
||||
? {
|
||||
kind: "interrupt_handoff_agent",
|
||||
tone: "neutral",
|
||||
text: "Interrupt current run, hand off to",
|
||||
chip: { kind: "agent", id: target.id },
|
||||
}
|
||||
: {
|
||||
kind: "wake_agent",
|
||||
tone: "neutral",
|
||||
text: "Wake",
|
||||
chip: { kind: "agent", id: target.id },
|
||||
};
|
||||
}
|
||||
if (target.kind === "user" && target.id) {
|
||||
return {
|
||||
kind: "user_handoff",
|
||||
tone: "neutral",
|
||||
text: "Hand off to",
|
||||
chip: { kind: "user", id: target.id },
|
||||
suffix: "— no agent will be notified",
|
||||
};
|
||||
}
|
||||
// Cleared / no target chosen for the mutation.
|
||||
return {
|
||||
kind: "clear_assignee",
|
||||
tone: "neutral",
|
||||
text: "Clear assignee — no agent will be notified",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.bodyHasAgentMention) {
|
||||
return {
|
||||
kind: "notify_agent",
|
||||
tone: "neutral",
|
||||
text: "Notify",
|
||||
chip: input.mentionedAgentId ? { kind: "agent", id: input.mentionedAgentId } : undefined,
|
||||
suffix: input.mentionedAgentId ? undefined : "the mentioned agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.plainNameCandidate) {
|
||||
return {
|
||||
kind: "plain_text_only",
|
||||
tone: "warn",
|
||||
text: "No agent will be notified. Use @ to mention an agent.",
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "none", tone: "neutral", text: "" };
|
||||
}
|
||||
|
||||
// --- Timeline assignee handoff / wake classification --------------------------
|
||||
|
||||
export type AssigneeHandoffKind = "agent_wake" | "user_handoff" | "unassigned";
|
||||
|
||||
export interface TimelineAssigneeLike {
|
||||
agentId: string | null;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface AssigneeHandoffInfo {
|
||||
kind: AssigneeHandoffKind;
|
||||
/** Copy rendered after the "Wake" label in the activity card. */
|
||||
wakeText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the wake outcome of an assignee change, given the *destination*
|
||||
* assignee. This drives the timeline "Wake" sub-row so the three required
|
||||
* states are self-describing in the activity log.
|
||||
*/
|
||||
export function classifyAssigneeHandoff(
|
||||
to: TimelineAssigneeLike,
|
||||
opts: { agentName?: string | null; interruptedRunAttached?: boolean } = {},
|
||||
): AssigneeHandoffInfo {
|
||||
if (to.agentId) {
|
||||
const who = opts.agentName ?? "the assigned agent";
|
||||
const suffix = opts.interruptedRunAttached ? " (interrupted run attached)" : "";
|
||||
return { kind: "agent_wake", wakeText: `queued for ${who}${suffix}` };
|
||||
}
|
||||
if (to.userId) {
|
||||
return {
|
||||
kind: "user_handoff",
|
||||
wakeText: "not created — this is a handoff to a board user",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "unassigned",
|
||||
wakeText: "not created — no agent selected. Mention @agent or pick an assignee to dispatch.",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Standalone assignee picker interrupt (PAP-10675, design surface 2) -------
|
||||
|
||||
export interface ReassignInterruptCopy {
|
||||
/** `role=status` banner shown while a run is live and the picker is open. */
|
||||
banner: string;
|
||||
/** Heading for the "Interrupt & assign" confirm step. */
|
||||
confirmTitle: string;
|
||||
/** Primary action label for the confirm step. */
|
||||
confirmAction: string;
|
||||
/** Label for backing out of the confirm step. */
|
||||
cancelAction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the assignee picker's live-run states: a banner warning that an
|
||||
* in-flight run will be interrupted, and the confirm step shown when the
|
||||
* operator picks a *different* target mid-run. Naming the running agent keeps
|
||||
* the interrupt consequence concrete instead of a bare "are you sure".
|
||||
*/
|
||||
export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy {
|
||||
const who = opts.runningAgentName?.trim() || "An agent";
|
||||
return {
|
||||
banner: `${who} is running — changing the assignee will interrupt this run.`,
|
||||
confirmTitle: "Interrupt the current run?",
|
||||
confirmAction: "Interrupt & assign",
|
||||
cancelAction: "Cancel",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Pause/hold "What this affects" buckets (PAP-10675, design surface 4) ------
|
||||
|
||||
export type PauseAffectsBucketKey =
|
||||
| "live_runs"
|
||||
| "queued_wakes"
|
||||
| "agent_owned"
|
||||
| "human_owned"
|
||||
| "static";
|
||||
|
||||
export interface PauseAffectsIssueLike {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
activeRun: { status: "queued" | "running" } | null;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface PauseAffectsBucket {
|
||||
key: PauseAffectsBucketKey;
|
||||
label: string;
|
||||
count: number;
|
||||
/** One-line clarifier of what pausing does to this bucket. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface PauseAffectsSummary {
|
||||
buckets: PauseAffectsBucket[];
|
||||
/** Total non-skipped issues the operation affects. */
|
||||
affectedIssueCount: number;
|
||||
/** True when no run is live or queued — there is nothing to interrupt. */
|
||||
nothingLive: boolean;
|
||||
}
|
||||
|
||||
const PAUSE_BUCKET_LABEL: Record<PauseAffectsBucketKey, string> = {
|
||||
live_runs: "Live agent runs",
|
||||
queued_wakes: "Queued wakes",
|
||||
agent_owned: "Agent-owned",
|
||||
human_owned: "Human-owned",
|
||||
static: "Static",
|
||||
};
|
||||
|
||||
const PAUSE_BUCKET_DETAIL: Record<PauseAffectsBucketKey, string> = {
|
||||
live_runs: "interrupted now, re-queued when you resume",
|
||||
queued_wakes: "held — they won't start until you resume",
|
||||
agent_owned: "assigned to an agent; no run is live",
|
||||
human_owned: "owned by a board user; pausing won't notify them",
|
||||
static: "no assignee; nothing was going to run",
|
||||
};
|
||||
|
||||
/**
|
||||
* Partition the issues an operation affects into the five disjoint buckets the
|
||||
* pause dialog summarises. Each non-skipped issue lands in exactly one bucket:
|
||||
* a live run, a queued wake, or — when nothing is in flight — by owner kind.
|
||||
*/
|
||||
export function computePauseAffectsSummary(
|
||||
issues: readonly PauseAffectsIssueLike[],
|
||||
): PauseAffectsSummary {
|
||||
const counts: Record<PauseAffectsBucketKey, number> = {
|
||||
live_runs: 0,
|
||||
queued_wakes: 0,
|
||||
agent_owned: 0,
|
||||
human_owned: 0,
|
||||
static: 0,
|
||||
};
|
||||
let affectedIssueCount = 0;
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.skipped) continue;
|
||||
affectedIssueCount += 1;
|
||||
if (issue.activeRun?.status === "running") counts.live_runs += 1;
|
||||
else if (issue.activeRun?.status === "queued") counts.queued_wakes += 1;
|
||||
else if (issue.assigneeAgentId) counts.agent_owned += 1;
|
||||
else if (issue.assigneeUserId) counts.human_owned += 1;
|
||||
else counts.static += 1;
|
||||
}
|
||||
|
||||
const order: PauseAffectsBucketKey[] = [
|
||||
"live_runs",
|
||||
"queued_wakes",
|
||||
"agent_owned",
|
||||
"human_owned",
|
||||
"static",
|
||||
];
|
||||
|
||||
return {
|
||||
buckets: order.map((key) => ({
|
||||
key,
|
||||
label: PAUSE_BUCKET_LABEL[key],
|
||||
count: counts[key],
|
||||
detail: PAUSE_BUCKET_DETAIL[key],
|
||||
})),
|
||||
affectedIssueCount,
|
||||
nothingLive: counts.live_runs === 0 && counts.queued_wakes === 0,
|
||||
};
|
||||
}
|
||||
@@ -325,6 +325,39 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flags an operator-interrupted historical run so the timeline can read 'interrupted'", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-int",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" },
|
||||
},
|
||||
{
|
||||
runId: "run-plain",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
createdAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:04:00.000Z"),
|
||||
resultJson: null,
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
});
|
||||
|
||||
const interrupted = messages.find((message) => message.id === "run-assistant:run-int");
|
||||
const plain = messages.find((message) => message.id === "run-assistant:run-plain");
|
||||
expect(interrupted?.metadata?.custom).toMatchObject({ runOperatorInterrupted: true });
|
||||
expect(plain?.metadata?.custom).toMatchObject({ runOperatorInterrupted: false });
|
||||
});
|
||||
|
||||
it("redacts deleted comment bodies while preserving tombstone metadata", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [
|
||||
@@ -914,6 +947,39 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("labels error-code-only operator interruptions as interrupted by board", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-interrupted",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
errorCode: "operator_interrupted",
|
||||
resultJson: null,
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-interrupted", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-interrupted",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.metadata.custom).toMatchObject({
|
||||
chainOfThoughtLabel: "Interrupted by board after 1 minute",
|
||||
runOperatorInterrupted: true,
|
||||
runStatus: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import type { Agent, IssueComment } from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { formatAssigneeUserLabel } from "./assignees";
|
||||
import { isOperatorInterruptedRun } from "./interrupt-handoff";
|
||||
import {
|
||||
buildIssueThreadInteractionSummary,
|
||||
type IssueThreadInteraction,
|
||||
@@ -45,6 +46,7 @@ export interface IssueChatLinkedRun {
|
||||
finishedAt?: Date | string | null;
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
errorCode?: string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -595,6 +597,7 @@ function runDurationLabel(run: {
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
errorCode?: string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const start = run.startedAt ?? run.createdAt;
|
||||
@@ -611,6 +614,9 @@ function runDurationLabel(run: {
|
||||
case "timed_out":
|
||||
return durationText ? `Timed out after ${durationText}` : "Run timed out";
|
||||
case "cancelled":
|
||||
if (isOperatorInterruptedRun(run.resultJson, run.errorCode)) {
|
||||
return durationText ? `Interrupted by board after ${durationText}` : "Interrupted by board";
|
||||
}
|
||||
if (stopReason === "paused") {
|
||||
return durationText ? `Paused by board after ${durationText}` : "Paused by board";
|
||||
}
|
||||
@@ -639,6 +645,7 @@ function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<stri
|
||||
runAgentId: run.agentId,
|
||||
runAgentName: agentName,
|
||||
runStatus: run.status,
|
||||
runOperatorInterrupted: isOperatorInterruptedRun(run.resultJson, run.errorCode),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -675,6 +682,7 @@ function createHistoricalTranscriptMessage(args: {
|
||||
runAgentId: run.agentId,
|
||||
runAgentName: agentName,
|
||||
runStatus: run.status,
|
||||
runOperatorInterrupted: isOperatorInterruptedRun(run.resultJson, run.errorCode),
|
||||
notices,
|
||||
waitingText,
|
||||
chainOfThoughtLabel: runDurationLabel(run),
|
||||
|
||||
Reference in New Issue
Block a user