mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
fix(ui): include native human assignments in reply defaults
This commit is contained in:
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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user