diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index e53dcafe77..27b2b1accd 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -67,6 +67,11 @@ Tasks have parentage. Every task exists in service of a parent task, all the way The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks. +When a task is assigned to the current human, the message composer defaults to +the latest agent that assigned it to them. If no assignment is available, it uses +the latest other commenter, or leaves the recipient empty. A manual recipient +choice stays selected while task data refreshes. + ### Company Skills and Policy Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction. diff --git a/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts index 36d17da5c5..860af32ab4 100644 --- a/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts +++ b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts @@ -78,6 +78,7 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", () async function seed() { const company = await db.insert(companies).values({ name: "Attribution Company", + issueCounter: 2, issuePrefix: `AT${randomUUID().replaceAll("-", "").slice(0, 6).toUpperCase()}`, }).returning().then((rows) => rows[0]!); const [actorAgent, targetAgent] = await db.insert(agents).values(["Actor", "Target"].map((name) => ({ @@ -162,6 +163,27 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", () }; } + it("records the human recipient for REST task and child creation", async () => { + const fixture = await seed(); + const app = await createApp(db, agentActor(fixture)); + for (const [path, action] of [ + [`/api/companies/${fixture.company.id}/issues`, "issue.created"], + [`/api/issues/${fixture.issue.id}/children`, "issue.child_created"], + ]) { + const response = await request(app).post(path!).send({ + title: "Task for the human", status: "todo", assigneeUserId: fixture.responsibleUserId, + }); + expect(response.status, JSON.stringify(response.body)).toBe(201); + const events = await db.select().from(activityLog).where(and( + eq(activityLog.entityId, response.body.id), eq(activityLog.action, action!), + )); + expect(events).toEqual([expect.objectContaining({ + actorType: "agent", actorId: fixture.actorAgent.id, + details: expect.objectContaining({ assigneeAgentId: null, assigneeUserId: fixture.responsibleUserId }), + })]); + } + }); + it("attributes foreign-issue agent comments and emits complete agent and human PATCH receipts", async () => { const fixture = await seed(); const agentApp = await createApp(db, agentActor(fixture)); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index fba3795594..66b7d0460b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -662,7 +662,7 @@ const attachmentArtifactMetadataInputSchema = z .passthrough(); function buildCreateIssueActivityStatusDetails( - issue: { assigneeAgentId: string | null; status: string }, + issue: { assigneeAgentId: string | null; assigneeUserId: string | null; status: string }, res: Response, ) { const statusDefault = res.locals.createIssueStatusDefault as @@ -670,6 +670,8 @@ function buildCreateIssueActivityStatusDetails( const assignmentWakeSkipped = !issue.assigneeAgentId || issue.status === "backlog"; return { + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, status: issue.status, statusDefaulted: statusDefault?.defaulted ?? false, statusDefaultReason: statusDefault?.reason ?? "explicit", diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index e8f2e574f4..058500e8d8 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1286,7 +1286,7 @@ export class PaperclipRunnerToolAuthority { agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id, action: "issue.created", entityType: "issue", entityId: child.id, details: { identifier: child.identifier, title: child.title, parentId: child.parentId, - assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" }, + assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" }, }); publication = activity.publication; } @@ -1486,7 +1486,8 @@ export class PaperclipRunnerToolAuthority { agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId, action: "issue.reassigned", entityType: "issue", entityId: taskId, details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint, - previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt }, + previousAssigneeAgentId: target.assigneeAgentId, previousAssigneeUserId: target.assigneeUserId, + assigneeAgentId, assigneeUserId, reason, changed, receipt }, }); publication = activity.publication; return receipt; diff --git a/server/src/services/native-runtime/task-reassignment.test.ts b/server/src/services/native-runtime/task-reassignment.test.ts index f28276130d..8f3d86b063 100644 --- a/server/src/services/native-runtime/task-reassignment.test.ts +++ b/server/src/services/native-runtime/task-reassignment.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { eq, and } from "drizzle-orm"; -import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; +import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { issueService } from "../issues.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; @@ -49,6 +49,24 @@ describe("runner task reassignment", () => { expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1); }); + it("records human assignment fields for native creation and reassignment", async () => { + const f = await fixture(); + const person = randomUUID(); + await db.insert(authUsers).values({ id: person, name: "Reviewer", email: `${person}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: person, status: "active", membershipRole: "operator" }); + await f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: null, assigneeUserId: person } }); + const [reassigned] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.targetId), eq(activityLog.action, "issue.reassigned"))); + expect(reassigned).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { + previousAssigneeAgentId: f.agentId, previousAssigneeUserId: null, + assigneeAgentId: null, assigneeUserId: person, changed: true, + } }); + const created = await f.authority.execute({ tool: "create_task", callId: "human-child", arguments: { + title: "Review result", assigneeUserId: person, idempotencyKey: "human-child", + } }) as { task: { id: string } }; + const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, created.task.id), eq(activityLog.action, "issue.created"))); + expect(creation).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { assigneeAgentId: null, assigneeUserId: person } }); + }); + it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => { const f = await fixture(status); await f.authority.execute(f.call); diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index 649758e26e..da87912cf8 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -9,7 +9,7 @@ import { // Real local Board UI, upload storage, comment HTTP routes, and disposable DB. // Only the interface flag and explicit transport/upload rejection faults are mocked. -// Tasks belong to the Board user: no agent, runner, or provider is contacted. +// Tasks have no recipient: no agent, runner, or provider is contacted. type Attachment = { id: string; issueCommentId: string | null; @@ -44,7 +44,9 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) { data: { title: "Inspect the newly uploaded Board files", status: "todo", - assigneeUserId: "local-board", + // Keep attachment sends on the comment route. A task assigned to the + // current human now suggests an empty recipient and sends a reassignment. + assigneeUserId: null, }, }), ); @@ -78,6 +80,14 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) { composer, editor, send, + sendComment: async () => { + await send.click(); + if (classic) { + const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway"); + await expect(confirm).toBeVisible(); + await confirm.click(); + } + }, attachments: () => request .get(`/api/issues/${issue.id}/attachments`) @@ -183,7 +193,7 @@ for (const classic of [false, true]) { req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await fixture.sendComment(); expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual( receipts.map((row) => row.id).sort(), ); @@ -244,7 +254,7 @@ for (const classic of [false, true]) { accepted = true; await route.abort("connectionreset"); }); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); expect(acceptedRequestId).toEqual(expect.any(String)); @@ -276,7 +286,7 @@ for (const classic of [false, true]) { await fixture.editor.fill( "A deliberately new comment after the original receipt settled.", ); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(2); }); @@ -298,14 +308,14 @@ for (const classic of [false, true]) { body: JSON.stringify({ error: "Fixture policy rejected this attempt" }), }); }); - await fixture.send.click(); + await fixture.sendComment(); await expect(fixture.editor).toContainText("Known rejection,"); await expect(fixture.send).toBeEnabled(); expect(await fixture.comments()).toHaveLength(0); await page.reload(); await expect(fixture.editor).toContainText("Known rejection,"); await expect(fixture.composer.getByRole("alert")).toHaveCount(0); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect( (await fixture.attachments()).find((row) => row.id === receipt.id) @@ -339,7 +349,7 @@ for (const classic of [false, true]) { await route.fulfill({ response }).catch(() => {}); }); try { - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); await fixture.editor.fill("A newer draft written while delivery was pending."); @@ -354,7 +364,7 @@ for (const classic of [false, true]) { expect(await fixture.comments()).toHaveLength(1); expect(acceptedRequestId).toEqual(expect.any(String)); expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(2); expect(attempts).toBe(2); expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([ @@ -412,7 +422,7 @@ for (const classic of [false, true]) req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await fixture.sendComment(); expect((await outbound).postDataJSON().attachmentIds).toBeUndefined(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect((await fixture.comments())[0]!.body).not.toContain( @@ -450,7 +460,7 @@ test("legacy failed upload can be removed before sending the retained text", asy }); await expect(remove).toBeVisible(); await remove.click(); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect((await fixture.comments())[0]!.body).toBe( "Keep this text after removing the failed file.", diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 427d5f62ea..a6f05717e4 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./ApprovalCard", () => ({ @@ -78,6 +85,34 @@ describe("CommentThread", () => { let writeTextMock: ReturnType Promise>>; let execCommandMock: ReturnType>; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 328bf906ca..287f350e17 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -752,6 +752,12 @@ export function CommentThread({ const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); @@ -852,8 +858,12 @@ export function CommentThread({ }, []); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { @@ -898,6 +908,7 @@ export function CommentThread({ try { await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -1051,7 +1062,7 @@ export function CommentThread({ noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="text-xs h-8" renderTriggerValue={(option) => { if (!option) return Responsible; diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5650ba0e7e..6f6b2bc2eb 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./Identity", () => ({ @@ -375,6 +382,33 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} enableLiveTranscriptPolling={false} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); function renderIssueThread(root: ReturnType, props: Partial> = {}) { root.render( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 2eb81a46ef..695d9a168e 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -4763,6 +4763,12 @@ const IssueChatComposer = forwardRef< const dragDepthRef = useRef(0); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState( effectiveSuggestedAssigneeValue, ); @@ -4919,8 +4925,12 @@ const IssueChatComposer = forwardRef< }, [draftKey]); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); useEffect(() => { setPendingWorkMode(resolvedIssueWorkMode); @@ -5065,6 +5075,7 @@ const IssueChatComposer = forwardRef< setComposerAttachments((current) => current.filter((item) => !submittedAttachmentKeys.has(item.id)), ); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); setRunSettings(null); } catch (error) { @@ -5606,7 +5617,7 @@ const IssueChatComposer = forwardRef< overrides={assigneeAdapterOverrides} settings={runSettings} onSettingsChange={setRunSettings} - onAssigneeChange={setReassignTarget} + onAssigneeChange={selectAssignee} triggerRef={reassignTriggerRef} renderAssigneeIdentity={(value) => { const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null; @@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef< noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="h-8 text-xs" renderTriggerValue={(option) => { if (!option) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c1425f56e7..de97aa93eb 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -527,6 +527,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode, reassignOptions, currentAssigneeValue, + suggestedAssigneeValue, assigneeAdapterOverrides, issueStatus, issueAssigneeAgentId = null, @@ -604,12 +605,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { const [pendingComposerAssignee, setPendingComposerAssignee] = useState< string | null >(null); - const effectiveGoalAgentId = - pendingComposerAssignee === null - ? issueAssigneeAgentId - : pendingComposerAssignee.startsWith("agent:") - ? pendingComposerAssignee.slice("agent:".length) || null - : null; + const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}` + ? suggestedAssigneeValue + : undefined; + const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee; + const effectiveGoalAgentId = effectiveComposerAssignee === undefined + ? issueAssigneeAgentId + : effectiveComposerAssignee.startsWith("agent:") + ? effectiveComposerAssignee.slice("agent:".length) || null + : null; const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId); useEffect(() => { @@ -3240,6 +3244,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { modelAgents={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} + suggestedAssigneeValue={composerSuggestedAssignee} companyId={companyId} assigneeAdapterOverrides={assigneeAdapterOverrides} onPendingAssigneeChange={setPendingComposerAssignee} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 769a4d4c50..be889270e0 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1701,6 +1701,25 @@ describe("TaskChatComposer", () => { ).toBe("agent:a2"); }); + it("uses assignment suggestions and retains a manual human choice after refresh", async () => { + const view = (suggestion: string) => ; + render(view("agent:a1")); + const trigger = () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!; + expect(trigger().textContent).toContain("Alpha"); + flushSync(() => root!.render(view("agent:a2"))); + expect(trigger().textContent).toContain("Beta"); + flushSync(() => trigger().click()); + await flushAsync(); + const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!; + flushSync(() => me.click()); + await flushAsync(); + flushSync(() => root!.render(view("agent:a1"))); + expect(trigger().textContent).toContain("Me"); + }); + it("shows human avatars in assignee options and after selection", async () => { render( | null; currentAssigneeValue?: string; + suggestedAssigneeValue?: string; onPendingAssigneeChange?: (value: string | null) => void; issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ @@ -397,6 +398,7 @@ export function TaskChatComposer({ assigneeAdapterOverrides, userProfileMap, currentAssigneeValue = "", + suggestedAssigneeValue, onPendingAssigneeChange, issueStatus, mobile = false, @@ -638,7 +640,7 @@ export function TaskChatComposer({ const showAssignee = Boolean( enableReassign && reassignOptions && reassignOptions.length > 0, ); - const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue; + const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue; const assigneeLabel = reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; const assigneeName = diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts index 66b4f3f1bc..36f912816e 100644 --- a/ui/src/lib/assignees.test.ts +++ b/ui/src/lib/assignees.test.ts @@ -1,3 +1,4 @@ +import type { ActivityEvent } from "@paperclipai/shared"; import { describe, expect, it } from "vitest"; import { assigneeValueFromSelection, @@ -72,14 +73,14 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); - it("falls back to the actual assignee when there is no better commenter hint", () => { + it("leaves the recipient empty instead of defaulting to the current human", () => { expect( suggestedCommentAssigneeValue( { assigneeUserId: "board-user" }, [{ authorUserId: "board-user" }], "board-user", ), - ).toBe("user:board-user"); + ).toBe(""); }); it("skips the current agent when choosing a suggested commenter assignee", () => { @@ -97,3 +98,71 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); }); + + +describe("assignment history suggestions", () => { + const assignment = (actorId: string, date: string, details: Record = {}): ActivityEvent => ({ + companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null, + id: actorId, actorType: "agent", actorId, action: "issue.updated", + createdAt: new Date(date), details: { assigneeUserId: "me", _previous: { assigneeUserId: null }, ...details }, + }); + const suggest = (activity: ActivityEvent[]) => suggestedCommentAssigneeValue( + { assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, activity, + ); + + it("uses the latest assigning actor in either history order, ahead of commenters", () => { + const older = assignment("first", "2026-01-01"); + const newer = assignment("last", "2026-02-01"); + expect(suggest([older, newer])).toBe("agent:last"); + expect(suggest([newer, older])).toBe("agent:last"); + }); + + it("ignores other recipients, human actors, non-assignment events and unchanged fields", () => { + const valid = assignment("assigner", "2026-01-01"); + const invalid = assignment("other", "2026-02-01"); + expect(suggest([ + valid, + { ...invalid, actorType: "user" }, + { ...invalid, action: "issue.comment_added" }, + assignment("other-user", "2026-02-01", { assigneeUserId: "someone-else" }), + assignment("no-op", "2026-02-01", { _previous: { assigneeUserId: "me" } }), + assignment("resent", "2026-02-01", { _previous: { status: "todo" } }), + ])).toBe("agent:assigner"); + }); + + it("includes the agent that created a task assigned to the human", () => { + expect(suggest([{ ...assignment("creator", "2026-01-01"), action: "issue.created" }])).toBe("agent:creator"); + }); + + it("keeps the existing commenter fallback and other task defaults", () => { + expect(suggest([])).toBe("agent:commenter"); + expect(suggestedCommentAssigneeValue({ assigneeAgentId: "owner" }, [], "me")).toBe("agent:owner"); + expect(suggestedCommentAssigneeValue({ assigneeUserId: "someone-else" }, [], "me")).toBe("user:someone-else"); + }); +}); + + +it("prefers a newer native reassignment over a REST assignment and ignores native no-ops", () => { + const event = (actorId: string, action: string, createdAt: string, details: Record): ActivityEvent => ({ + id: actorId, companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null, + actorType: "agent", actorId, action, createdAt: new Date(createdAt), details, + }); + const history = [ + event("alpha", "issue.updated", "2026-01-01", { assigneeUserId: "me", _previous: { assigneeUserId: null } }), + event("beta", "issue.reassigned", "2026-02-01", { assigneeUserId: "me", previousAssigneeUserId: null, changed: true }), + event("no-op", "issue.reassigned", "2026-03-01", { assigneeUserId: "me", previousAssigneeUserId: "me", changed: false }), + ]; + expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [], "me", undefined, history)).toBe("agent:beta"); +}); + + +it.each(["issue.created", "issue.child_created"])("uses a REST %s receipt assigned to the current human", (action) => { + const event: ActivityEvent = { + id: "creation", companyId: "company", entityType: "issue", entityId: "task", + agentId: "creator", runId: null, actorType: "agent", actorId: "creator", action, + createdAt: new Date("2026-02-01"), + details: { title: "Task", identifier: "PAP-1", status: "todo", assigneeAgentId: null, assigneeUserId: "me" }, + }; + expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, [event])).toBe("agent:creator"); + expect(suggestedCommentAssigneeValue({ assigneeUserId: "other" }, [], "other", undefined, [event])).toBe(""); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 2e45490cd9..1992442cc9 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -1,3 +1,5 @@ +import type { ActivityEvent } from "@paperclipai/shared"; + export interface AssigneeSelection { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -30,7 +32,32 @@ export function suggestedCommentAssigneeValue( comments: CommentAssigneeSuggestionComment[] | null | undefined, currentUserId: string | null | undefined, currentAgentId?: string | null | undefined, + activity: readonly ActivityEvent[] = [], ): string { + const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId); + if (assignedToCurrentUser) { + const assignment = activity + .filter((event) => { + if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false; + const details = event.details; + if (!details || details.assigneeUserId !== currentUserId) return false; + if (event.action === "issue.created" || event.action === "issue.child_created") return true; + if (event.action === "issue.reassigned") { + return details.changed === true + && Object.hasOwn(details, "previousAssigneeUserId") + && details.previousAssigneeUserId !== currentUserId; + } + if (event.action !== "issue.updated") return false; + const previous = details._previous; + // Updates can resend unchanged fields. Only a real assignment counts. + return previous !== null && typeof previous === "object" + && Object.hasOwn(previous, "assigneeUserId") + && (previous as Record).assigneeUserId !== currentUserId; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; + if (assignment) return `agent:${assignment.actorId}`; + } + if (comments && comments.length > 0 && (currentUserId || currentAgentId)) { for (let i = comments.length - 1; i >= 0; i--) { const comment = comments[i]; @@ -43,7 +70,7 @@ export function suggestedCommentAssigneeValue( } } - return assigneeValueFromSelection(issue); + return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue); } export function parseAssigneeValue(value: string): AssigneeSelection { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b7ebb693a9..af019ce5e2 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3850,14 +3850,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS [issue], ); + const { data: assignmentActivity } = useQuery({ + queryKey: queryKeys.issues.activity(issueId!), + queryFn: () => activityApi.forIssue(issueId!), + enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId), + }); + const suggestedAssigneeValue = useMemo( () => suggestedCommentAssigneeValue( issue ?? {}, mergeIssueComments(comments ?? [], optimisticComments), currentUserId, + undefined, + assignmentActivity, ), - [issue, comments, optimisticComments, currentUserId], + [issue, comments, optimisticComments, currentUserId, assignmentActivity], ); const threadComments = useMemo( diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx index 55b4b16271..1aa8b6ea62 100644 --- a/ui/src/pages/Pipelines.tsx +++ b/ui/src/pages/Pipelines.tsx @@ -2177,8 +2177,10 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str starterAssigneeValue ? parseAssigneeValue(starterAssigneeValue) : activeConversationIssue ?? {}, conversationComments, currentUserId, + undefined, + conversationActivity, ), - [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue], + [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue, conversationActivity], ); const conversationRunningRun = useMemo( () => resolveRunningPipelineConversationRun(resolvedConversationActiveRun, resolvedConversationLiveRuns),