Implement explicit issue follow-up intent

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-24 13:32:06 -05:00
1 parent 855471b6a0
commit fcaa681400
13 files changed
+435 -15

No files matched your search

+3
View File
@@ -61,6 +61,7 @@ interface IssueUpdateOptions extends BaseClientOptions {
interface IssueCommentOptions extends BaseClientOptions {
body: string;
reopen?: boolean;
resume?: boolean;
}
interface IssueCheckoutOptions extends BaseClientOptions {
@@ -241,12 +242,14 @@ export function registerIssueCommands(program: Command): void {
.argument("<issueId>", "Issue ID")
.requiredOption("--body <text>", "Comment body")
.option("--reopen", "Reopen if issue is done/cancelled")
.option("--resume", "Request explicit follow-up and wake the assignee when resumable")
.action(async (issueId: string, opts: IssueCommentOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = addIssueCommentSchema.parse({
body: opts.body,
reopen: opts.reopen,
resume: opts.resume,
});
const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload);
printOutput(comment, { json: ctx.json });
@@ -92,6 +92,7 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
"- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.",
"- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.",
"- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.",
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
"- If blocked, mark the issue blocked and name the unblock owner and action.",
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
+2 -2
View File
@@ -450,7 +450,7 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
),
makeTool(
"paperclipUpdateIssue",
"Patch an issue, optionally including a comment",
"Patch an issue, optionally including a comment; include resume=true when intentionally requesting follow-up on resumable closed work",
updateIssueToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("PATCH", `/issues/${encodeURIComponent(issueId)}`, { body }),
@@ -475,7 +475,7 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
),
makeTool(
"paperclipAddComment",
"Add a comment to an issue",
"Add a comment to an issue; include resume=true when intentionally requesting follow-up on resumable closed work",
addCommentToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/comments`, { body }),
@@ -28,6 +28,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
list: vi.fn(),
resolveByReference: vi.fn(),
}));
@@ -175,7 +176,7 @@ async function normalizePolicy(input: {
return normalizeIssueExecutionPolicy(input);
}
function makeIssue(status: "todo" | "done" | "blocked") {
function makeIssue(status: "todo" | "done" | "blocked" | "cancelled" | "in_progress") {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
@@ -188,6 +189,16 @@ function makeIssue(status: "todo" | "done" | "blocked") {
};
}
function agentActor(agentId = "22222222-2222-4222-8222-222222222222") {
return {
type: "agent",
agentId,
companyId: "company-1",
source: "agent_key",
runId: "run-1",
};
}
async function waitForWakeup(assertion: () => void) {
await vi.waitFor(assertion);
}
@@ -211,6 +222,7 @@ describe.sequential("issue comment reopen routes", () => {
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.cancelRun.mockReset();
mockAgentService.getById.mockReset();
mockAgentService.list.mockReset();
mockAgentService.resolveByReference.mockReset();
mockLogActivity.mockReset();
mockFeedbackService.listIssueVotesForUser.mockReset();
@@ -272,6 +284,18 @@ describe.sequential("issue comment reopen routes", () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.hasPermission.mockResolvedValue(false);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.list.mockResolvedValue([
{
id: "22222222-2222-4222-8222-222222222222",
reportsTo: null,
permissions: { canCreateAgents: false },
},
{
id: "44444444-4444-4444-8444-444444444444",
reportsTo: null,
permissions: { canCreateAgents: false },
},
]);
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, reference: string) => {
if (reference === "ambiguous-codex") {
return { ambiguous: true, agent: null };
@@ -739,6 +763,158 @@ describe.sequential("issue comment reopen routes", () => {
);
});
it("explicit same-agent resume works through the PATCH comment path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const res = await request(await installActor(createApp(), agentActor()))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "please validate the follow-up", resume: true });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
status: "todo",
actorAgentId: "22222222-2222-4222-8222-222222222222",
actorUserId: null,
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.comment_added",
details: expect.objectContaining({
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "done",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
});
it("keeps generic same-agent comments on closed issues inert", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "follow-up note without intent" });
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("explicit same-agent resume comments reopen closed issues and mark the wake payload", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("done"),
...patch,
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please validate the follow-up", resume: true });
expect(res.status).toBe(201);
expect(mockIssueService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
{ status: "todo" },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.comment_added",
details: expect.objectContaining({
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
reason: "issue_reopened_via_comment",
payload: expect.objectContaining({
commentId: "comment-1",
reopenedFrom: "done",
resumeIntent: true,
followUpRequested: true,
}),
contextSnapshot: expect.objectContaining({
wakeReason: "issue_reopened_via_comment",
resumeIntent: true,
followUpRequested: true,
}),
}),
);
});
it("rejects explicit agent resume intent from a non-assignee", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
const res = await request(await installActor(createApp(), agentActor("44444444-4444-4444-8444-444444444444")))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "restart someone else's work", resume: true });
expect(res.status).toBe(403);
expect(res.body.error).toBe("Agent cannot request follow-up for another agent's issue");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("rejects explicit resume intent under an active pause hold", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue({
holdId: "hold-1",
rootIssueId: "root-1",
issueId: "11111111-1111-4111-8111-111111111111",
isRoot: false,
mode: "pause",
reason: "reviewing",
releasePolicy: null,
});
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please resume", resume: true });
expect(res.status).toBe(409);
expect(res.body.error).toBe("Issue follow-up blocked by active subtree pause hold");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects explicit resume intent on cancelled issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("cancelled"));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "please resume", resume: true });
expect(res.status).toBe(409);
expect(res.body.error).toBe("Cancelled issues must be restored through the dedicated restore flow");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
+11
View File
@@ -2404,6 +2404,7 @@ export function issueRoutes(
issueId: issue.id,
...(comment ? { commentId: comment.id } : {}),
mutation: "update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@@ -2418,6 +2419,7 @@ export function issueRoutes(
}
: {}),
source: "issue.update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@@ -2435,6 +2437,7 @@ export function issueRoutes(
payload: {
issueId: issue.id,
mutation: "update",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@@ -2442,6 +2445,7 @@ export function issueRoutes(
contextSnapshot: {
issueId: issue.id,
source: "issue.status_change",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@@ -2463,6 +2467,7 @@ export function issueRoutes(
commentId: comment.id,
mutation: "comment",
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@@ -2475,6 +2480,7 @@ export function issueRoutes(
source: reopened ? "issue.comment.reopen" : "issue.comment",
wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented",
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@@ -3306,6 +3312,7 @@ export function issueRoutes(
reopened: true,
reopenedFrom: reopenFromStatus,
source: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
identifier: currentIssue.identifier,
},
});
@@ -3412,6 +3419,7 @@ export function issueRoutes(
commentId: comment.id,
reopenedFrom: reopenFromStatus,
mutation: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@@ -3424,6 +3432,7 @@ export function issueRoutes(
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
@@ -3436,6 +3445,7 @@ export function issueRoutes(
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType,
@@ -3447,6 +3457,7 @@ export function issueRoutes(
wakeCommentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
},
});
+40
View File
@@ -178,6 +178,46 @@ describe("CommentThread", () => {
});
});
it("shows follow-up badges on explicit follow-up comments and timeline rows", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
}]}
timelineEvents={[{
id: "event-1",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-03-11T10:00:00.000Z"),
commentId: "comment-1",
followUpRequested: true,
}]}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Follow-up");
expect(container.textContent).toContain("requested follow-up");
act(() => {
root.unmount();
});
});
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
const root = createRoot(container);
const onAdd = vi.fn(async () => {});
+24 -7
View File
@@ -9,6 +9,7 @@ import type {
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
@@ -32,6 +33,7 @@ interface CommentWithRunMeta extends IssueComment {
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
followUpRequested?: boolean;
}
interface LinkedRunItem {
@@ -315,6 +317,7 @@ function CommentCard({
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
const followUpRequested = comment.followUpRequested === true;
return (
<div
@@ -345,6 +348,11 @@ function CommentCard({
Queued
</span>
) : null}
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{companyId && !isPending ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
@@ -452,6 +460,7 @@ function TimelineEventCard({
currentUserId?: string | null;
}) {
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
const actionLabel = event.followUpRequested ? "requested follow-up" : "updated this task";
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
@@ -462,7 +471,7 @@ function TimelineEventCard({
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<span className="text-muted-foreground">{actionLabel}</span>
<a
href={`#activity-${event.id}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
@@ -682,12 +691,20 @@ export function CommentThread({
const hasScrolledRef = useRef(false);
const timeline = useMemo<TimelineItem[]>(() => {
const commentItems: TimelineItem[] = comments.map((comment) => ({
kind: "comment",
id: comment.id,
createdAtMs: new Date(comment.createdAt).getTime(),
comment,
}));
const followUpCommentIds = new Set(
timelineEvents
.filter((event) => event.followUpRequested && event.commentId)
.map((event) => event.commentId as string),
);
const commentItems: TimelineItem[] = comments.map((comment) => {
const followUpRequested = comment.followUpRequested === true || followUpCommentIds.has(comment.id);
return {
kind: "comment",
id: comment.id,
createdAtMs: new Date(comment.createdAt).getTime(),
comment: followUpRequested ? { ...comment, followUpRequested } : comment,
};
});
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
kind: "approval",
id: approval.id,
@@ -318,6 +318,50 @@ describe("IssueChatThread", () => {
});
});
it("shows explicit follow-up badges and event copy", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
}]}
linkedRuns={[]}
timelineEvents={[{
id: "event-1",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-03-11T10:00:00.000Z"),
commentId: "comment-1",
followUpRequested: true,
}]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Follow-up");
expect(container.textContent).toContain("requested follow-up");
act(() => {
root.unmount();
});
});
it("shows unresolved blocker context above the composer", () => {
const root = createRoot(container);
+16 -1
View File
@@ -58,6 +58,7 @@ import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "..
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
@@ -1196,6 +1197,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
const followUpRequested = custom.followUpRequested === true;
const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null;
const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
const pending = custom.clientStatus === "pending";
@@ -1221,6 +1223,11 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
</div>
<div
className={cn(
@@ -1396,6 +1403,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
};
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
const followUpRequested = custom.followUpRequested === true;
return (
<div id={anchorId}>
@@ -1429,6 +1437,11 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
) : (
<div className="mb-1.5 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{authorName}</span>
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{isRunning ? (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
<Loader2 className="h-3 w-3 animate-spin" />
@@ -1944,7 +1957,9 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
<div className="min-w-0 space-y-1">
<div className={cn("flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-xs", isCurrentUser && "justify-end")}>
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<span className="text-muted-foreground">
{custom.followUpRequested === true ? "requested follow-up" : "updated this task"}
</span>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
+6 -1
View File
@@ -31,6 +31,7 @@ export interface IssueChatComment extends IssueComment {
queueState?: "queued";
queueTargetRunId?: string | null;
queueReason?: "hold" | "active_run" | "other";
followUpRequested?: boolean;
}
export interface IssueChatLinkedRun {
@@ -319,6 +320,7 @@ function createCommentMessage(args: {
queueTargetRunId: comment.queueTargetRunId ?? null,
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
};
if (comment.authorAgentId) {
@@ -357,7 +359,9 @@ function createTimelineEventMessage(args: {
? "System"
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
const lines: string[] = [`${actorName} updated this issue`];
const lines: string[] = [
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
];
if (event.statusChange) {
lines.push(
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
@@ -388,6 +392,7 @@ function createTimelineEventMessage(args: {
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
followUpRequested: event.followUpRequested === true,
},
},
};
+74
View File
@@ -126,6 +126,80 @@ describe("extractIssueTimelineEvents", () => {
]);
});
it("marks explicit follow-up timeline updates", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
status: "todo",
reopened: true,
reopenedFrom: "done",
source: "comment",
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
statusChange: {
from: "done",
to: "todo",
},
},
]);
});
it("synthesizes non-status follow-up rows from comment activity", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-comment-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.comment_added",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-comment-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
},
]);
});
it("ignores issue updates without visible status or assignee transitions", () => {
const events = extractIssueTimelineEvents([
{
+24 -3
View File
@@ -18,6 +18,8 @@ export interface IssueTimelineEvent {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
commentId?: string | null;
followUpRequested?: boolean;
}
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -53,11 +55,26 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
const events: IssueTimelineEvent[] = [];
for (const event of activity ?? []) {
if (event.action !== "issue.updated") continue;
const details = asRecord(event.details);
if (!details) continue;
if (event.action === "issue.comment_added") {
if (details.followUpRequested !== true && details.resumeIntent !== true) continue;
if (details.reopened === true) continue;
const commentId = nullableString(details.commentId);
events.push({
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
commentId,
followUpRequested: true,
});
continue;
}
if (event.action !== "issue.updated") continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
@@ -65,6 +82,10 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
actorType: event.actorType,
actorId: event.actorId,
};
if (details.followUpRequested === true || details.resumeIntent === true) {
timelineEvent.followUpRequested = true;
timelineEvent.commentId = nullableString(details.commentId);
}
if (hasOwn(details, "status")) {
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
@@ -96,7 +117,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) {
events.push(timelineEvent);
}
}
+13
View File
@@ -692,6 +692,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const followUpCommentIds = new Set<string>();
const agentIdByRunId = new Map<string, string>();
for (const run of resolvedLinkedRuns) {
@@ -710,10 +711,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
interruptedRunId,
});
}
for (const evt of resolvedActivity) {
if (evt.action !== "issue.comment_added") continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId) continue;
if (details["followUpRequested"] === true || details["resumeIntent"] === true) {
followUpCommentIds.add(commentId);
}
}
return comments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
if (followUpCommentIds.has(comment.id)) {
nextComment.followUpRequested = true;
}
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
queuedTargetRunId,