mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Implement explicit issue follow-up intent
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2abdf92478
commit
805f827439
13 files changed
+435
-15
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -300,6 +300,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);
|
||||
|
||||
|
||||
@@ -57,6 +57,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,
|
||||
@@ -1178,6 +1179,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";
|
||||
@@ -1203,6 +1205,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(
|
||||
@@ -1378,6 +1385,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
};
|
||||
|
||||
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
|
||||
const followUpRequested = custom.followUpRequested === true;
|
||||
|
||||
return (
|
||||
<div id={anchorId}>
|
||||
@@ -1411,6 +1419,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" />
|
||||
@@ -1926,7 +1939,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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user