fix(ui): include native human assignments in reply defaults

This commit is contained in:
cryppadotta committed 2026-10-08 17:38:15 +00:00
1 parent 285d20499c
commit cc7ed050de
5 files changed
+63 -13

No files matched your search

@@ -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;
@@ -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);
+22 -10
View File
@@ -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<T>(
}
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<ReturnType<typeof setup>>) {
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.",
+14
View File
@@ -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<string, unknown>): 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");
});
+5
View File
@@ -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.