mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +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,
|
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
|
||||||
action: "issue.created", entityType: "issue", entityId: child.id,
|
action: "issue.created", entityType: "issue", entityId: child.id,
|
||||||
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
|
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;
|
publication = activity.publication;
|
||||||
}
|
}
|
||||||
@@ -1475,7 +1475,8 @@ export class PaperclipRunnerToolAuthority {
|
|||||||
agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId,
|
agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId,
|
||||||
action: "issue.reassigned", entityType: "issue", entityId: taskId,
|
action: "issue.reassigned", entityType: "issue", entityId: taskId,
|
||||||
details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint,
|
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;
|
publication = activity.publication;
|
||||||
return receipt;
|
return receipt;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { eq, and } from "drizzle-orm";
|
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 { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||||
import { issueService } from "../issues.js";
|
import { issueService } from "../issues.js";
|
||||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.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);
|
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 => {
|
it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => {
|
||||||
const f = await fixture(status);
|
const f = await fixture(status);
|
||||||
await f.authority.execute(f.call);
|
await f.authority.execute(f.call);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@playwright/test";
|
} from "@playwright/test";
|
||||||
|
|
||||||
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
|
// 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.
|
// Tasks belong to the Board user: no agent, runner, or provider is contacted.
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +27,10 @@ async function body<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setup(page: Page, request: APIRequestContext, classic: boolean) {
|
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 }>(
|
const company = await body<{ id: string; issuePrefix: string }>(
|
||||||
await request.post("/api/companies", {
|
await request.post("/api/companies", {
|
||||||
data: { name: `Board receipt browser ${randomUUID()}` },
|
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 = [
|
const files = [
|
||||||
{
|
{
|
||||||
name: "board-fresh.txt",
|
name: "board-fresh.txt",
|
||||||
@@ -176,7 +188,7 @@ for (const classic of [false, true]) {
|
|||||||
req.method() === "POST" &&
|
req.method() === "POST" &&
|
||||||
new URL(req.url()).pathname.endsWith("/comments"),
|
new URL(req.url()).pathname.endsWith("/comments"),
|
||||||
);
|
);
|
||||||
await fixture.send.click();
|
await sendBoardReply(page, fixture);
|
||||||
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
|
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
|
||||||
receipts.map((row) => row.id).sort(),
|
receipts.map((row) => row.id).sort(),
|
||||||
);
|
);
|
||||||
@@ -237,7 +249,7 @@ for (const classic of [false, true]) {
|
|||||||
accepted = true;
|
accepted = true;
|
||||||
await route.abort("connectionreset");
|
await route.abort("connectionreset");
|
||||||
});
|
});
|
||||||
await fixture.send.click();
|
await sendBoardReply(page, fixture);
|
||||||
await expect.poll(() => accepted).toBe(true);
|
await expect.poll(() => accepted).toBe(true);
|
||||||
expect(await fixture.comments()).toHaveLength(1);
|
expect(await fixture.comments()).toHaveLength(1);
|
||||||
expect(acceptedRequestId).toEqual(expect.any(String));
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
||||||
@@ -269,7 +281,7 @@ for (const classic of [false, true]) {
|
|||||||
await fixture.editor.fill(
|
await fixture.editor.fill(
|
||||||
"A deliberately new comment after the original receipt settled.",
|
"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);
|
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" }),
|
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.editor).toContainText("Known rejection,");
|
||||||
await expect(fixture.send).toBeEnabled();
|
await expect(fixture.send).toBeEnabled();
|
||||||
expect(await fixture.comments()).toHaveLength(0);
|
expect(await fixture.comments()).toHaveLength(0);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(fixture.editor).toContainText("Known rejection,");
|
await expect(fixture.editor).toContainText("Known rejection,");
|
||||||
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
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);
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
||||||
@@ -332,7 +344,7 @@ for (const classic of [false, true]) {
|
|||||||
await route.fulfill({ response }).catch(() => {});
|
await route.fulfill({ response }).catch(() => {});
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await fixture.send.click();
|
await sendBoardReply(page, fixture);
|
||||||
await expect.poll(() => accepted).toBe(true);
|
await expect.poll(() => accepted).toBe(true);
|
||||||
expect(await fixture.comments()).toHaveLength(1);
|
expect(await fixture.comments()).toHaveLength(1);
|
||||||
await fixture.editor.fill("A newer draft written while delivery was pending.");
|
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(await fixture.comments()).toHaveLength(1);
|
||||||
expect(acceptedRequestId).toEqual(expect.any(String));
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
||||||
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
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);
|
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
||||||
expect(attempts).toBe(2);
|
expect(attempts).toBe(2);
|
||||||
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
|
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
|
||||||
@@ -405,7 +417,7 @@ for (const classic of [false, true])
|
|||||||
req.method() === "POST" &&
|
req.method() === "POST" &&
|
||||||
new URL(req.url()).pathname.endsWith("/comments"),
|
new URL(req.url()).pathname.endsWith("/comments"),
|
||||||
);
|
);
|
||||||
await fixture.send.click();
|
await sendBoardReply(page, fixture);
|
||||||
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
|
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
|
||||||
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||||
expect((await fixture.comments())[0]!.body).not.toContain(
|
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 expect(remove).toBeVisible();
|
||||||
await remove.click();
|
await remove.click();
|
||||||
await fixture.send.click();
|
await sendBoardReply(page, fixture);
|
||||||
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||||
expect((await fixture.comments())[0]!.body).toBe(
|
expect((await fixture.comments())[0]!.body).toBe(
|
||||||
"Keep this text after removing the failed file.",
|
"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");
|
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;
|
const details = event.details;
|
||||||
if (!details || details.assigneeUserId !== currentUserId) return false;
|
if (!details || details.assigneeUserId !== currentUserId) return false;
|
||||||
if (event.action === "issue.created") return true;
|
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;
|
if (event.action !== "issue.updated") return false;
|
||||||
const previous = details._previous;
|
const previous = details._previous;
|
||||||
// Updates can resend unchanged fields. Only a real assignment counts.
|
// Updates can resend unchanged fields. Only a real assignment counts.
|
||||||
|
|||||||
Reference in new issue
Block a user