mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
Preserve human author identity in task chat and align only the current viewer on the right. Show other humans and agents on the left with their own identity. Include mixed-author Storybook journeys and regression coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
|
import {
|
|
commentsToTaskChatItems,
|
|
formatTaskChatTimestamp,
|
|
} from "./task-chat-adapter";
|
|
|
|
describe("commentsToTaskChatItems", () => {
|
|
it("preserves distinct human IDs, photos and viewer ownership, including after a profile refresh", () => {
|
|
const comments = ["alex", "sam"].map(authorUserId => ({
|
|
id: `comment-${authorUserId}`, authorType: "user", authorUserId,
|
|
authorAgentId: null, body: "A human message", createdAt: "2026-10-09T20:42:00Z",
|
|
} as unknown as IssueChatComment));
|
|
const userProfileMap = new Map([
|
|
["alex", { label: "Alex Morgan", image: "/alex.png" }],
|
|
["sam", { label: "Sam Rivera", image: "/sam.png" }],
|
|
]);
|
|
expect(commentsToTaskChatItems(comments, { currentUserId: "alex", userProfileMap })).toMatchObject([
|
|
{ authorUserId: "alex", authorName: "Alex Morgan", authorAvatarUrl: "/alex.png", isCurrentUser: true },
|
|
{ authorUserId: "sam", authorName: "Sam Rivera", authorAvatarUrl: "/sam.png", isCurrentUser: false },
|
|
]);
|
|
expect(commentsToTaskChatItems(comments, { currentUserId: "sam", userProfileMap })).toMatchObject([
|
|
{ authorUserId: "alex", isCurrentUser: false },
|
|
{ authorUserId: "sam", isCurrentUser: true },
|
|
]);
|
|
expect(commentsToTaskChatItems(comments, { currentUserId: "alex" })).toMatchObject([
|
|
{ authorName: "You", isCurrentUser: true, authorAvatarUrl: null },
|
|
{ authorName: "User", isCurrentUser: false, authorAvatarUrl: null },
|
|
]);
|
|
// Directory hydration changes presentation without changing canonical message IDs.
|
|
expect(commentsToTaskChatItems(comments, { currentUserId: "alex", userProfileMap })[1]).toMatchObject({
|
|
id: "comment-sam", authorName: "Sam Rivera", authorAvatarUrl: "/sam.png",
|
|
});
|
|
});
|
|
|
|
it("does not infer ownership from matching names or an unresolved viewer", () => {
|
|
const comment = { id: "other", authorType: "user", authorUserId: "other", body: "Hello" } as IssueChatComment;
|
|
expect(commentsToTaskChatItems([comment], {
|
|
currentUserId: "me", userLabelMap: new Map([["me", "Alex"], ["other", "Alex"]]),
|
|
})[0]).toMatchObject({ authorName: "Alex", isCurrentUser: false });
|
|
expect(commentsToTaskChatItems([comment])[0]).toMatchObject({ authorName: "User", isCurrentUser: false });
|
|
});
|
|
it("carries inbound channel attribution into the human bubble", () => {
|
|
expect(commentsToTaskChatItems([{
|
|
id: "photon-comment",
|
|
body: "From my phone",
|
|
authorType: "user",
|
|
authorUserId: "local-board",
|
|
metadata: { version: 1, sourceChannel: "imessage-photon", sections: [] },
|
|
createdAt: "2026-09-12T12:00:00Z",
|
|
} as unknown as IssueChatComment])).toMatchObject([{
|
|
author: "human", sourceChannel: "imessage-photon", text: "From my phone",
|
|
}]);
|
|
});
|
|
it("classifies a recovered local-board comment as an agent bubble", () => {
|
|
const items = commentsToTaskChatItems([{
|
|
id: "c-recovered",
|
|
body: "Recovered agent reply.",
|
|
authorType: "user",
|
|
authorUserId: "local-board",
|
|
authorAgentId: null,
|
|
derivedAuthorAgentId: "agent-1",
|
|
createdAt: "2026-08-07T09:00:00.000Z",
|
|
} as unknown as IssueChatComment]);
|
|
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({ kind: "message", author: "agent" });
|
|
});
|
|
|
|
it("never tags posted comments interstitial — the run's final reply keeps its bubble", () => {
|
|
const comments = [
|
|
{
|
|
id: "c1",
|
|
body: "Here is the finished work.",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-1",
|
|
runId: "run-1",
|
|
createdAt: "2026-07-31T12:00:10.000Z",
|
|
} as unknown as IssueChatComment,
|
|
];
|
|
const items = commentsToTaskChatItems(comments);
|
|
expect(items).toHaveLength(1);
|
|
const item = items[0];
|
|
expect(item.kind).toBe("message");
|
|
if (item.kind !== "message") return;
|
|
expect(item.author).toBe("agent");
|
|
expect(item.interstitial).toBeUndefined();
|
|
});
|
|
|
|
it("classifies system comments as system even with a derivable run→agent linkage (PAP-443)", () => {
|
|
const comments = [
|
|
{
|
|
id: "c-sys",
|
|
body: "Paperclip automatically retried dispatch, but it still has no live execution path.",
|
|
authorType: "system",
|
|
authorAgentId: null,
|
|
derivedAuthorAgentId: "agent-1",
|
|
createdAt: "2026-08-07T09:00:00.000Z",
|
|
} as unknown as IssueChatComment,
|
|
];
|
|
const items = commentsToTaskChatItems(comments);
|
|
expect(items).toHaveLength(1);
|
|
const item = items[0];
|
|
if (item.kind !== "message") throw new Error("expected message item");
|
|
expect(item.author).toBe("system");
|
|
});
|
|
|
|
it("attaches verification caveats using durable comment run provenance", () => {
|
|
const verificationCaveats = [{
|
|
commandOrCheck: "external-validator",
|
|
reasonCode: "tool_unavailable",
|
|
detail: "The optional validator is unavailable.",
|
|
}];
|
|
const comments = [{
|
|
id: "c-final",
|
|
body: "Implemented and locally verified.",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-1",
|
|
createdByRunId: "run-1",
|
|
createdAt: "2026-08-22T09:00:00.000Z",
|
|
} as unknown as IssueChatComment];
|
|
|
|
const items = commentsToTaskChatItems(comments, {
|
|
verificationCaveatsByRunId: new Map([["run-1", verificationCaveats]]),
|
|
});
|
|
|
|
expect(items).toEqual([expect.objectContaining({
|
|
kind: "message",
|
|
verificationCaveats,
|
|
})]);
|
|
});
|
|
|
|
it("carries presentation, metadata, and the raw timestamp for system comments", () => {
|
|
const presentation = {
|
|
kind: "system_notice",
|
|
tone: "warning",
|
|
title: "Run recovery",
|
|
detailsDefaultOpen: true,
|
|
};
|
|
const metadata = {
|
|
version: 1,
|
|
sections: [{ rows: [{ type: "text", label: "Reason", text: "quota" }] }],
|
|
};
|
|
const comments = [
|
|
{
|
|
id: "c-sys",
|
|
body: "Recovery notice.",
|
|
authorType: "system",
|
|
presentation,
|
|
metadata,
|
|
runAgentId: "agent-1",
|
|
createdAt: new Date("2026-08-07T09:00:00.000Z"),
|
|
} as unknown as IssueChatComment,
|
|
{
|
|
id: "c-agent",
|
|
body: "Agent reply.",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-1",
|
|
presentation: null,
|
|
metadata,
|
|
createdAt: "2026-08-07T09:01:00.000Z",
|
|
} as unknown as IssueChatComment,
|
|
];
|
|
const items = commentsToTaskChatItems(comments);
|
|
const [sys, agent] = items;
|
|
if (sys.kind !== "message" || agent.kind !== "message") throw new Error("expected messages");
|
|
expect(sys.presentation).toEqual(presentation);
|
|
expect(sys.metadata).toEqual(metadata);
|
|
expect(sys.runAgentId).toBe("agent-1");
|
|
expect(sys.createdAtIso).toBe("2026-08-07T09:00:00.000Z");
|
|
// Non-system authors keep the item lean — no structured notice fields.
|
|
expect(agent.presentation).toBeUndefined();
|
|
expect(agent.metadata).toBeUndefined();
|
|
expect(agent.runAgentId).toBeUndefined();
|
|
expect(agent.createdAtIso).toBeUndefined();
|
|
});
|
|
|
|
it("keeps the regular comment time for a causally repositioned steered follow-up", () => {
|
|
const createdAt = "2026-09-04T14:09:33.000Z";
|
|
const conversationAnchorAt = "2026-09-04T14:10:14.000Z";
|
|
const [item] = commentsToTaskChatItems([
|
|
{
|
|
id: "c-steered",
|
|
body: "Use three seconds instead.",
|
|
authorType: "user",
|
|
authorUserId: "user-1",
|
|
authorAgentId: null,
|
|
followUpRequested: true,
|
|
consumedByRunId: "run-1",
|
|
steeredIntoRunId: "run-1",
|
|
conversationAnchorAt,
|
|
createdAt,
|
|
} as unknown as IssueChatComment,
|
|
]);
|
|
|
|
expect(item).toMatchObject({
|
|
kind: "message",
|
|
timestamp: formatTaskChatTimestamp(createdAt),
|
|
});
|
|
});
|
|
|
|
it("preserves human identity for steered and ordinary messages", () => {
|
|
const comment = {
|
|
id: "steered-by-colleague", body: "Check the latest result.",
|
|
authorType: "user", authorUserId: "colleague", steeredIntoRunId: "run-1",
|
|
createdAt: "2026-09-04T14:09:33.000Z",
|
|
} as unknown as IssueChatComment;
|
|
const ctx = {
|
|
currentUserId: "viewer",
|
|
userProfileMap: new Map([["colleague", { label: "Sam Rivera", image: "/api/assets/sam/content" }]]),
|
|
};
|
|
for (const steeredIntoRunId of ["run-1", null]) {
|
|
expect(commentsToTaskChatItems([{ ...comment, steeredIntoRunId }], ctx)[0]).toMatchObject({
|
|
authorName: "Sam Rivera", authorAvatarUrl: "/api/assets/sam/content", isCurrentUser: false,
|
|
});
|
|
}
|
|
expect(commentsToTaskChatItems([{ ...comment, authorUserId: "viewer" }], ctx)[0]).toMatchObject({
|
|
isCurrentUser: true,
|
|
});
|
|
});
|
|
|
|
it("keeps the regular comment time for a successor-run follow-up", () => {
|
|
const createdAt = "2026-09-04T14:09:33.000Z";
|
|
const conversationAnchorAt = "2026-09-04T14:10:35.000Z";
|
|
const [item] = commentsToTaskChatItems([
|
|
{
|
|
id: "c-delivered",
|
|
body: "Use three seconds instead.",
|
|
authorType: "user",
|
|
authorUserId: "user-1",
|
|
authorAgentId: null,
|
|
followUpRequested: true,
|
|
consumedByRunId: "run-2",
|
|
conversationAnchorAt,
|
|
createdAt,
|
|
} as unknown as IssueChatComment,
|
|
]);
|
|
|
|
expect(item).toMatchObject({
|
|
kind: "message",
|
|
timestamp: formatTaskChatTimestamp(createdAt),
|
|
});
|
|
});
|
|
|
|
it("keeps an ordinary first message on the compact timestamp", () => {
|
|
const createdAt = "2026-09-04T14:09:33.000Z";
|
|
const [item] = commentsToTaskChatItems([
|
|
{
|
|
id: "c-initial",
|
|
body: "Start the task.",
|
|
authorType: "user",
|
|
authorUserId: "user-1",
|
|
authorAgentId: null,
|
|
consumedByRunId: "run-1",
|
|
conversationAnchorAt: "2026-09-04T14:10:35.000Z",
|
|
createdAt,
|
|
} as unknown as IssueChatComment,
|
|
]);
|
|
|
|
expect(item).toMatchObject({
|
|
kind: "message",
|
|
timestamp: formatTaskChatTimestamp(createdAt),
|
|
});
|
|
});
|
|
|
|
it("routes an agent-authored workspace-ready notice through the system renderer", () => {
|
|
const presentation = {
|
|
kind: "system_notice",
|
|
tone: "info",
|
|
title: "Workspace ready · fix/workspace-ready-notice",
|
|
detailsDefaultOpen: false,
|
|
density: "compact",
|
|
} as const;
|
|
const metadata = {
|
|
version: 1,
|
|
sections: [
|
|
{
|
|
title: "Workspace",
|
|
rows: [
|
|
{ type: "key_value", label: "Strategy", value: "git_worktree" },
|
|
{
|
|
type: "key_value",
|
|
label: "Branch",
|
|
value: "fix/workspace-ready-notice",
|
|
},
|
|
{ type: "key_value", label: "CWD", value: "/worktrees/workspace-ready-notice" },
|
|
],
|
|
},
|
|
],
|
|
} as const;
|
|
const items = commentsToTaskChatItems([
|
|
{
|
|
id: "workspace-ready",
|
|
body: "## Workspace Ready\n\n- Strategy: `git_worktree`",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-1",
|
|
createdByRunId: "run-1",
|
|
presentation,
|
|
metadata,
|
|
createdAt: "2026-09-02T12:59:03.318Z",
|
|
} as unknown as IssueChatComment,
|
|
]);
|
|
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({
|
|
id: "workspace-ready",
|
|
kind: "message",
|
|
author: "system",
|
|
presentation,
|
|
metadata,
|
|
runAgentId: null,
|
|
createdAtIso: "2026-09-02T12:59:03.318Z",
|
|
});
|
|
expect(items[0]).toHaveProperty("authorName", undefined);
|
|
expect(items[0]).toHaveProperty("agentIcon", undefined);
|
|
});
|
|
|
|
it("keeps an agent comment with message presentation as an agent bubble", () => {
|
|
const [item] = commentsToTaskChatItems([
|
|
{
|
|
id: "agent-message",
|
|
body: "Implementation is complete.",
|
|
authorType: "agent",
|
|
authorAgentId: "agent-1",
|
|
presentation: { kind: "message" },
|
|
metadata: { version: 1, sections: [] },
|
|
createdAt: "2026-09-02T13:00:00.000Z",
|
|
} as unknown as IssueChatComment,
|
|
]);
|
|
|
|
expect(item).toMatchObject({ kind: "message", author: "agent" });
|
|
if (item.kind !== "message") throw new Error("expected message item");
|
|
expect(item.presentation).toBeUndefined();
|
|
expect(item.metadata).toBeUndefined();
|
|
});
|
|
});
|