From cc7ed050deea6ab87277ca626e6099b31c5f527d Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:38:15 +0000 Subject: [PATCH] fix(ui): include native human assignments in reply defaults --- .../paperclip-runner-tool-authority.ts | 5 +-- .../native-runtime/task-reassignment.test.ts | 20 +++++++++++- tests/e2e/board-attachment-receipts.spec.ts | 32 +++++++++++++------ ui/src/lib/assignees.test.ts | 14 ++++++++ ui/src/lib/assignees.ts | 5 +++ 5 files changed, 63 insertions(+), 13 deletions(-) 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 de950a6b7a..59d7a1e2b0 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1275,7 +1275,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; } @@ -1475,7 +1475,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 de7fb5cbde..65d208e741 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -8,7 +8,7 @@ import { } from "@playwright/test"; // Real local Board UI, upload storage, comment HTTP routes, and disposable DB. -// Only the interface flag and explicit transport/upload rejection faults are mocked. +// Only the interface flag, announcement feed, and explicit transport/upload rejection faults are mocked. // Tasks belong to the Board user: no agent, runner, or provider is contacted. type Attachment = { id: string; @@ -27,6 +27,10 @@ async function body( } async function setup(page: Page, request: APIRequestContext, classic: boolean) { + // Unrelated live announcements must not cover the attachment controls. + await page.route("**/api/announcements/current", (route) => + route.fulfill({ contentType: "application/json", body: "null" }), + ); const company = await body<{ id: string; issuePrefix: string }>( await request.post("/api/companies", { data: { name: `Board receipt browser ${randomUUID()}` }, @@ -80,6 +84,14 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) { }; } +// Human-owned tasks no longer default replies to the current person. These +// attachment tests explicitly retain Board ownership, including after reload. +async function sendBoardReply(page: Page, fixture: Awaited>) { + await fixture.composer.getByRole("button", { name: "Select assignee", exact: true }).click(); + await page.getByTestId("composer-model-popover").getByRole("option").filter({ has: page.getByText("Me", { exact: true }) }).click(); + await fixture.send.click(); +} + const files = [ { name: "board-fresh.txt", @@ -176,7 +188,7 @@ for (const classic of [false, true]) { req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await sendBoardReply(page, fixture); expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual( receipts.map((row) => row.id).sort(), ); @@ -237,7 +249,7 @@ for (const classic of [false, true]) { accepted = true; await route.abort("connectionreset"); }); - await fixture.send.click(); + await sendBoardReply(page, fixture); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); expect(acceptedRequestId).toEqual(expect.any(String)); @@ -269,7 +281,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 sendBoardReply(page, fixture); await expect.poll(async () => (await fixture.comments()).length).toBe(2); }); @@ -291,14 +303,14 @@ for (const classic of [false, true]) { body: JSON.stringify({ error: "Fixture policy rejected this attempt" }), }); }); - await fixture.send.click(); + await sendBoardReply(page, fixture); 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 sendBoardReply(page, fixture); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect( (await fixture.attachments()).find((row) => row.id === receipt.id) @@ -332,7 +344,7 @@ for (const classic of [false, true]) { await route.fulfill({ response }).catch(() => {}); }); try { - await fixture.send.click(); + await sendBoardReply(page, fixture); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); await fixture.editor.fill("A newer draft written while delivery was pending."); @@ -347,7 +359,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 sendBoardReply(page, fixture); await expect.poll(async () => (await fixture.comments()).length).toBe(2); expect(attempts).toBe(2); expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([ @@ -405,7 +417,7 @@ for (const classic of [false, true]) req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await sendBoardReply(page, fixture); expect((await outbound).postDataJSON().attachmentIds).toBeUndefined(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect((await fixture.comments())[0]!.body).not.toContain( @@ -443,7 +455,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 sendBoardReply(page, fixture); 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/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts index ef9f805970..4628ea6c59 100644 --- a/ui/src/lib/assignees.test.ts +++ b/ui/src/lib/assignees.test.ts @@ -140,3 +140,17 @@ describe("assignment history suggestions", () => { 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"); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 4cedb90843..d49878ff03 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -42,6 +42,11 @@ export function suggestedCommentAssigneeValue( const details = event.details; if (!details || details.assigneeUserId !== currentUserId) return false; if (event.action === "issue.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.