diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 78b75d3a974..3774a58573e 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -225,6 +225,40 @@ describe("issue comment reopen routes", () => { ); }); + it("implicitly reopens closed issues via the PATCH comment path when reassigning to an agent", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + reopened: true, + reopenedFrom: "done", + status: "todo", + }), + }), + ); + }); + it("reopens closed issues via the PATCH comment path", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ @@ -259,6 +293,48 @@ describe("issue comment reopen routes", () => { ); }); + it("implicitly reopens closed issues via POST comments when an agent is assigned", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("done")); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue("done"), + ...patch, + })); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_reopened_via_comment", + payload: expect.objectContaining({ + reopenedFrom: "done", + }), + }), + ); + }); + + it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { + mockIssueService.getById.mockResolvedValue({ + ...makeIssue("done"), + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "hello" }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0d8da068a03..0d9c6876b14 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -142,6 +142,22 @@ function summarizeExecutionParticipants( ); } +function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" { + return status === "done" || status === "cancelled"; +} + +function shouldImplicitlyReopenCommentForAgent(input: { + issueStatus: string | null | undefined; + assigneeAgentId: string | null | undefined; + actorType: "agent" | "user"; + actorId: string; +}) { + if (!isClosedIssueStatus(input.issueStatus)) return false; + if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false; + if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false; + return true; +} + function diffExecutionParticipants( previousPolicy: NormalizedExecutionPolicy | null, nextPolicy: NormalizedExecutionPolicy | null, @@ -1339,7 +1355,7 @@ export function issueRoutes( if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); - const isClosed = existing.status === "done" || existing.status === "cancelled"; + const isClosed = isClosedIssueStatus(existing.status); const existingRelations = Array.isArray(req.body.blockedByIssueIds) ? await svc.getRelationSummaries(existing.id) @@ -1351,6 +1367,17 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const requestedAssigneeAgentId = + req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null); + const effectiveReopenRequested = + reopenRequested || + (!!commentBody && + shouldImplicitlyReopenCommentForAgent({ + issueStatus: existing.status, + assigneeAgentId: requestedAssigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + })); let interruptedRunId: string | null = null; const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; @@ -1393,7 +1420,7 @@ export function issueRoutes( if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } - if (commentBody && reopenRequested === true && isClosed && updateFields.status === undefined) { + if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) { updateFields.status = "todo"; } if (req.body.executionPolicy !== undefined) { @@ -1552,7 +1579,7 @@ export function issueRoutes( const hasFieldChanges = Object.keys(previous).length > 0; const reopened = commentBody && - reopenRequested === true && + effectiveReopenRequested && isClosed && previous.status !== undefined && issue.status === "todo"; @@ -1774,7 +1801,7 @@ export function issueRoutes( const selfComment = actorIsAgent && actor.actorId === assigneeId; const skipAssigneeCommentWake = selfComment || isClosed; - if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", @@ -2259,13 +2286,21 @@ export function issueRoutes( const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const interruptRequested = req.body.interrupt === true; - const isClosed = issue.status === "done" || issue.status === "cancelled"; + const isClosed = isClosedIssueStatus(issue.status); + const effectiveReopenRequested = + reopenRequested || + shouldImplicitlyReopenCommentForAgent({ + issueStatus: issue.status, + assigneeAgentId: issue.assigneeAgentId, + actorType: actor.actorType, + actorId: actor.actorId, + }); let reopened = false; let reopenFromStatus: string | null = null; let interruptedRunId: string | null = null; let currentIssue = issue; - if (reopenRequested && isClosed) { + if (effectiveReopenRequested && isClosed) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 0fa73210108..860ea0ad8f7 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -178,6 +178,52 @@ describe("CommentThread", () => { }); }); + it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => { + const root = createRoot(container); + const onAdd = vi.fn(async () => {}); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).not.toContain("Re-open"); + + const editor = container.querySelector('textarea[aria-label="Comment editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Comment", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Please pick this back up"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(onAdd).toHaveBeenCalledWith("Please pick this back up", true, undefined); + + act(() => { + root.unmount(); + }); + }); + it("renders linked approvals inline in the timeline", () => { const root = createRoot(container); const agent: Agent = { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f4ad17b5233..06fe76813d5 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -134,6 +134,11 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { + const isClosed = issueStatus === "done" || issueStatus === "cancelled"; + return isClosed && assigneeValue.startsWith("agent:"); +} + function humanizeValue(value: string | null): string { if (!value) return "None"; return value.replace(/_/g, " "); @@ -647,6 +652,7 @@ export function CommentThread({ pendingApprovalAction = null, onVote, onAdd, + issueStatus, agentMap, currentUserId, imageUploadHandler, @@ -663,7 +669,6 @@ export function CommentThread({ composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; @@ -784,14 +789,17 @@ export function CommentThread({ if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const reopen = shouldImplicitlyReopenComment( + issueStatus, + hasReassignment ? reassignTarget : currentAssigneeValue, + ) ? true : undefined; const submittedBody = trimmed; setSubmitting(true); setBody(""); try { - await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); - setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -935,15 +943,6 @@ export function CommentThread({ )} - {enableReassign && reassignOptions.length > 0 && ( ({ markdownEditorFocusMock: vi.fn(), })); +const { appendMock } = vi.hoisted(() => ({ + appendMock: vi.fn(async () => undefined), +})); + const { threadMessagesMock } = vi.hoisted(() => ({ threadMessagesMock: vi.fn(() =>
), })); @@ -32,7 +36,7 @@ vi.mock("@assistant-ui/react", () => ({ Content: () => null, Parts: () => null, }, - useAui: () => ({ thread: () => ({ append: vi.fn() }) }), + useAui: () => ({ thread: () => ({ append: appendMock }) }), useAuiState: () => false, useMessage: () => ({ id: "message", @@ -126,6 +130,7 @@ describe("IssueChatThread", () => { afterEach(() => { container.remove(); vi.useRealTimers(); + appendMock.mockReset(); markdownEditorFocusMock.mockReset(); threadMessagesMock.mockReset(); }); @@ -338,6 +343,64 @@ describe("IssueChatThread", () => { }); }); + it("hides the reopen control and infers reopen for closed agent-assigned issue replies", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).not.toContain("Re-open"); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + const submitButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === "Send", + ) as HTMLButtonElement | undefined; + expect(editor).not.toBeNull(); + expect(submitButton).toBeDefined(); + + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(editor, "Please pick this back up"); + editor?.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + submitButton?.click(); + }); + + expect(appendMock).toHaveBeenCalledWith( + expect.objectContaining({ + content: [{ type: "text", text: "Please pick this back up" }], + runConfig: { + custom: { + reopen: true, + }, + }, + }), + ); + + act(() => { + root.unmount(); + }); + }); + it("exposes a composer focus handle that forwards to the editor", () => { const root = createRoot(container); const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 68edd254243..9df088a4780 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -436,6 +436,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | return null; } +function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { + const isClosed = issueStatus === "done" || issueStatus === "cancelled"; + return isClosed && assigneeValue.startsWith("agent:"); +} + const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function commentDateLabel(date: Date | string | undefined): string { @@ -1609,7 +1614,6 @@ const IssueChatComposer = forwardRef @@ -1783,16 +1790,6 @@ const IssueChatComposer = forwardRef ) : null} - - {enableReassign && reassignOptions.length > 0 ? (