fix(inbox): keep automatic work out of Mine by default

Keep channel and routine attribution out of Mine unless the person participates directly, is assigned the task, or receives a current addressed request. Apply the same source rules to unread state, archives, and task notifications. Preserve direct comments and current human action requests.

Restore the two expressions damaged by redaction during the earlier master merge.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-20 13:46:10 -05:00
1 parent dc6dd23ea4
commit a2d26a83fe
16 files changed
+392 -122

No files matched your search

+19 -8
View File
@@ -111,14 +111,25 @@ ancestors and expandable subtasks as context without promoting unrelated automat
work to independent results. Agent profiles, routine histories, and task-detail
subtasks retain their full history.
Inbox membership continues to follow human creation, assignment, comments, and
existing qualifying participation actions, including manually running a routine.
Routine ownership or responsibility alone does not subscribe a person to scheduled
runs; opening or reading a task is not participation. Inbox discovery searches and
Blocked hide unrelated automated tasks unless the checkbox is checked, while
preserving tasks admitted by the existing participation rules. Checking it does
not subscribe the user to all automation. Approvals and other non-task items keep
their existing rules.
Mine contains personal work. Channel tasks, routine tasks, and agent-created tasks
stay outside Mine by default. This includes routines that a person starts manually.
Linked accounts, creator IDs, responsible-user IDs, synchronized provider comments,
and read markers do not establish participation in automatic work. Direct board or
human API comments, human assignment, and deliberate user mutations do establish
participation. Open requests addressed to the person also admit the task until
resolution. Ordinary human-created tasks keep their creator's participation.
These rules apply to existing tasks, Mine, unread counts, badges, and task toasts.
Approvals retain their existing recipient and permission rules.
New inbound comments retain their provider in `metadata.sourceChannel`. Historical
imports use inbound message links or the stored provider-sender metadata. These
checks preserve direct board comments on channel tasks. If both historical
records are absent, the comment remains unclassified and keeps its previous
behavior. A task origin alone cannot prove the source of an old comment.
Inbox discovery searches and Blocked hide unrelated automatic tasks unless the
checkbox is checked. The checkbox does not subscribe a person to automatic work.
Source feeds and project task lists retain their existing access rules.
### Company Skills and Policy
+9 -4
View File
@@ -1236,7 +1236,7 @@ Core authorization follows these rules:
- Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode.
- Concurrent archives keep the newest archive time and its actor attribution. An earlier request must not undo an archive written by human completion while it waited. Unarchive removes that state so a later archive starts fresh.
- New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work.
- Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch.
- Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with direct human creation of an ordinary task, human assignment, a direct board/API comment, or another deliberate audited user mutation. Open requests addressed to the person also admit the task until resolution. Channel and routine creator attribution, imported comments, and manual routine runs do not establish participation.
Failed-run rows in Mine and the inbox badge use the run's `responsibleUserId`.
Another user's run does not appear there, even for a shared agent. Select the
@@ -2437,9 +2437,14 @@ pagination, and retrieve hierarchy context separately under existing access chec
Context does not count as independent results or board cards. Bounded context
reads retain loaded rows while users reveal more of the tree.
Inbox membership, archives, unread state, and badges retain existing participation
rules, including assignment, comments, and manual routine runs. Responsibility and
read-only activity do not admit scheduled work. Inbox discovery/Blocked preserve
Mine, unread counts, badges, and task toasts use the same personal participation
rules. Automatic creator attribution, synchronized provider comments, manual routine
runs, responsibility, and read-only activity do not admit automatic work. Human
assignment, direct board/API participation, and open addressed requests still qualify.
Only current requests on open tasks qualify. Historical questions remain answerable
without adding tasks to Mine. A request stays unread until the person reads or
replies; automatic task updates do not mark requests as read.
Existing archive behavior and access checks remain in force. Inbox discovery/Blocked preserve
participated automation and hide unrelated automation by default. Agent profile
history, routine history, and task-detail subtasks remain complete. API consumers
that omit the optional creation filter retain their existing list behavior.
+5 -3
View File
@@ -6,12 +6,14 @@ mode (`local-board`). Access to a task does not subscribe someone to notificatio
## Which tasks qualify
A task qualifies when the viewer created it, is its current human assignee,
authored a comment, or performed a deliberate action included in the existing
A task qualifies when the viewer creates an ordinary human task, is its current
human assignee, comments directly in Paperclip, receives an open addressed request,
or performs a deliberate action included in the existing
Mine participation allowlist in
[`issue-participation.ts`](../server/src/services/issue-participation.ts).
Read receipts, denied requests, and automatic read-time recovery cleanup do not
count. Comment authorship follows Mine's existing historical participation rule.
count. Automatic creator attribution, synchronized comments, and manual routine
runs also do not count. Comment source checks apply to historical imports.
A task also qualifies when its current description or a non-deleted comment
contains an explicit mention of the viewer: `[Name](user://USER_ID)`. The shared
+6 -1
View File
@@ -36,7 +36,12 @@ Agent access: `partial` — REST / native call_api; MCP inbox/task search.
Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for
cross-user archive; decision policy separately. Automatic read cleanup does not
count as user participation, including historical reader-attributed rows.
Explicit user recovery decisions still count.
Explicit user recovery decisions still count. Automatic creator attribution,
synchronized comments, and manual routine runs do not count. Direct board/API
comments, human assignment, and open addressed requests qualify. These admission
rules do not grant task access.
Historical questions and requests on closed tasks do not qualify. Request unread
state uses the person's read or reply time, independent of automatic task updates.
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at
the reviewed commit above. The cleanup exclusion in
+6 -1
View File
@@ -52,7 +52,12 @@ cover exclusion of historical automatic read cleanup from user participation,
unread counts, and activity timestamps while retaining explicit user decisions
and audit history.
Manual: Seed a scheduled routine execution owned by the current human. It stays out of Mine until an existing participation rule admits it. Verify comments, assignment, and manual routine runs admit automated tasks while reading alone does not. Inbox discovery and Blocked hide unrelated automation by default; the **show agent-created tasks** checkbox reveals it without subscribing the user. Reload and verify membership, unread badges, archive/resurfacing, and preference persistence.
Automated: [Source regressions](../../server/src/__tests__/issues-creation-visibility.test.ts) cover channel attribution, imported comments, direct participation, addressed requests, unread results, and access checks. [Routine regressions](../../server/src/__tests__/routines-service.test.ts) cover fresh, coalesced, and skipped manual runs.
The source regressions also create an addressed request through the service for a
human assignee. They check unread lists, flags, and counts, plus exclusion of
historical questions and closed requests while preserving authorization requests.
Manual: Seed GitHub, Slack, scheduled routine, and manual routine tasks. Attribution, imported comments, and reading alone keep them outside Mine. Direct board/API comments, human assignment, and open addressed requests admit them. Resolve the request and check that another valid reason is needed. Inbox discovery and Blocked hide unrelated automation by default; the **show agent-created tasks** checkbox reveals it without subscribing the user. Reload and verify membership, unread badges, archive/resurfacing, and preference persistence.
Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation.
+2 -1
View File
@@ -1,3 +1,4 @@
import type { ChatProvider } from "./chat-channels.js";
import type { TaskWorkspaceIntent, TaskWorkspacePendingSelection } from "./task-workspace.js";
import type { ExecutionProjection, ExecutionBlocker } from "./execution-projection.js";
import type {
@@ -1159,7 +1160,7 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata {
version: 1;
/** Inbound channel attribution; never an authorization input. */
sourceChannel?: "imessage-photon";
sourceChannel?: ChatProvider;
sourceRunId?: string | null;
sourceIdentityContextId?: string | null;
authorizationReason?: string | null;
+2 -1
View File
@@ -1,3 +1,4 @@
import { CHAT_PROVIDERS } from "../types/chat-channels.js";
import { taskWorkspaceSelectionSchema } from "./task-workspace.js";
import { z } from "zod";
import { ISSUE_SUMMARIES_MAX_IDS } from "../constants.js";
@@ -1081,7 +1082,7 @@ export const issueCommentMetadataSectionSchema = z
export const issueCommentMetadataSchema = z
.object({
version: z.literal(1),
sourceChannel: z.literal("imessage-photon").optional(),
sourceChannel: z.enum(CHAT_PROVIDERS).optional(),
sourceRunId: z.string().guid().nullable().optional(),
authorizationReason: z
.string()
@@ -5757,6 +5757,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
expect(await resolveRunScopedMentionedSkillKeys({ db, companyId: f.companyId, issueId: task.id, commentIds: [comments[0].id] })).toEqual([skill.key]);
expect(comments[0].body).toContain("untrusted provider data");
expect(comments[0].authorUserId).toBe("owner-user");
expect(comments[0].metadata?.sourceChannel).toBe("github");
const inbox = await issueService(db).list(f.companyId, { touchedByUserId: "owner-user" });
expect(inbox.map((issue) => issue.id)).not.toContain(task.id);
const [run] = await db.insert(heartbeatRuns).values({
companyId: f.companyId,
agentId: f.assignedAgentId,
@@ -1,17 +1,27 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import request from "supertest";
import { expect, it } from "vitest";
import { activityLog, issueComments, issueReadStates, issues } from "@paperclipai/db";
import { activityLog, agents, authUsers, chatConversations, chatEndpoints, chatMessageLinks, issueComments, issueReadStates, issues, issueThreadInteractions, toolApplications, toolConnections } from "@paperclipai/db";
import { isAgentCreatedIssue } from "@paperclipai/shared";
import { issueRoutes } from "../routes/issues.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { describeEmbeddedPostgres, resetCompanyIssueFixtures, routeApp, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js";
describeEmbeddedPostgres("issue creation visibility", () => {
const ctx = useEmbeddedPostgres("paperclip-creation-visibility-", {
resetEach: async (db) => {
await db.delete(activityLog);
await db.delete(chatMessageLinks);
await db.delete(chatConversations);
await db.delete(chatEndpoints);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(issues);
await db.delete(agents);
await resetCompanyIssueFixtures(db);
await db.delete(authUsers);
},
});
@@ -67,11 +77,202 @@ describeEmbeddedPostgres("issue creation visibility", () => {
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
const mine = await request(app).get(`${path}?touchedByUserId=me&inboxArchivedByUserId=me&includeRoutineExecutions=true`).expect(200);
const expected = [ids.commented, ids.assigned, ids.manual, ids.touched, ids.channelCommented, ids.channelManual].sort();
const expected = [ids.commented, ids.assigned, ids.touched, ids.channelCommented].sort();
expect(mine.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
const discovery = await request(app).get(`${path}?q=Participation&includeAgentCreatedTasks=false&includeParticipatedTasks=true`).expect(200);
expect(discovery.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
const strict = await issueService(ctx.db).list(companyId, { includeAgentCreatedTasks: false });
expect(strict).toEqual([]);
});
it("uses source evidence for existing imports and keeps direct board/API participation", async () => {
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Personal inbox sources");
const svc = issueService(ctx.db);
const names = ["github", "slack", "legacySender", "legacyLink", "direct", "outbound", "manualRun", "human", "agent", "private"];
const ids = Object.fromEntries(names.map((name) => [name, randomUUID()]));
const old = new Date("2026-01-01T00:00:00Z");
const newer = new Date("2026-01-02T00:00:00Z");
const [agent] = await ctx.db.insert(agents).values({ companyId, name: "Worker", role: "engineer", adapterType: "process" }).returning();
await ctx.db.insert(issues).values(names.map((name) => ({
id: ids[name]!, companyId, title: `Source ${name}`, status: "todo",
originKind: name === "human" || name === "agent" ? "manual" : name === "manualRun" ? "routine_execution" : "chat_channel",
createdByUserId: name === "private" ? "other-user" : userId, createdByAgentId: name === "agent" ? agent!.id : null,
responsibleUserId: name === "private" ? "other-user" : userId, visibility: name === "private" ? "private" : "open",
createdAt: old, updatedAt: old,
})));
const comments = await ctx.db.insert(issueComments).values(
["github", "slack", "legacySender", "legacyLink", "direct", "outbound", "private"].map((name) => ({
companyId, issueId: ids[name]!, authorUserId: userId, authorType: "user" as const,
body: "User identity is attribution", createdAt: old,
metadata: name === "github" || name === "slack"
? { version: 1 as const, sourceChannel: name as "github" | "slack", sections: [] }
: name === "legacySender" ? { version: 1 as const, sections: [{ title: "GitHub sender", rows: [{ type: "key_value" as const, label: "Provider ID", value: "external-user" }] }] } : null,
})),
).returning();
const [appRow] = await ctx.db.insert(toolApplications).values({ companyId, name: "Chat", type: "mcp" }).returning();
const [connection] = await ctx.db.insert(toolConnections).values({ companyId, applicationId: appRow!.id, name: "GitHub", uid: randomUUID(), transport: "mcp_remote", authKind: "api_key" }).returning();
const [endpoint] = await ctx.db.insert(chatEndpoints).values({ companyId, connectionId: connection!.id, assignedAgentId: agent!.id, provider: "github", publicId: randomUUID(), sponsorUserId: userId }).returning();
const conversations = await ctx.db.insert(chatConversations).values(["legacyLink", "outbound"].map((name) => ({
companyId, endpointId: endpoint!.id, issueId: ids[name]!, externalConversationId: name, externalLabel: "Review",
}))).returning();
await ctx.db.insert(chatMessageLinks).values(["legacyLink", "outbound"].map((name) => ({
companyId, endpointId: endpoint!.id, conversationId: conversations.find((conversation) => conversation.issueId === ids[name])!.id,
commentId: comments.find((comment) => comment.issueId === ids[name])!.id,
providerMessageId: name, direction: name === "legacyLink" ? "inbound" : "outbound",
})));
await ctx.db.insert(activityLog).values({ companyId, entityType: "issue", entityId: ids.manualRun!, actorType: "user", actorId: userId, action: "issue.inbox_touched", details: { source: "manual_routine_run" } });
await ctx.db.insert(issueComments).values(names.map((name) => ({ companyId, issueId: ids[name]!, body: "New agent result", createdAt: newer })));
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
const expected = [ids.direct, ids.outbound, ids.human].sort();
for (const view of ["", "&view=compact"]) {
const mine = await request(app).get(`${path}?touchedByUserId=me&includeRoutineExecutions=true${view}`).expect(200);
expect(mine.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
const unread = await request(app).get(`${path}?unreadForUserId=me&includeRoutineExecutions=true${view}`).expect(200);
expect(unread.body.map((issue: { id: string }) => issue.id).sort()).toEqual(expected);
}
await ctx.db.delete(issues).where(eq(issues.id, ids.private!));
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(3);
await ctx.db.insert(issueComments).values({ companyId, issueId: ids.github!, authorUserId: userId, body: "Direct API reply" });
const beforeReply = await request(app).get(`${path}?includeRoutineExecutions=true&inboxArchivedByUserId=me`).expect(200);
const imported = beforeReply.body.find((issue: { id: string }) => issue.id === ids.slack);
expect(imported.myLastTouchAt).toBeNull();
expect(imported.isUnreadForMe).toBe(false);
const joined = await request(app).get(`${path}?touchedByUserId=me`).expect(200);
expect(joined.body.map((issue: { id: string }) => issue.id)).toContain(ids.github);
});
it("admits open addressed requests and removes that reason when they resolve", async () => {
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Human requests");
const ids = Object.fromEntries(["question", "confirmation", "otherUser", "creatorExcluded", "private"].map((name) => [name, randomUUID()]));
await ctx.db.insert(issues).values(Object.entries(ids).map(([name, id]) => ({ id, companyId, title: name, status: "todo", originKind: "chat_channel", visibility: name === "private" ? "private" : "open", createdAt: new Date("2026-01-01") })));
const requests = await ctx.db.insert(issueThreadInteractions).values(Object.entries(ids).map(([name, issueId]) => ({
companyId, issueId, kind: name === "confirmation" ? "request_confirmation" : "ask_user_questions",
addresseeUserId: name === "otherUser" ? "someone-else" : userId,
createdByUserId: name === "creatorExcluded" ? userId : null,
effectiveResolverPolicy: name === "creatorExcluded" ? "not_creator" as const : "human_only" as const,
payload: name === "confirmation" ? { version: 1 as const, prompt: "Accept this result?" } : { version: 1 as const, questions: [{ id: "answer", prompt: "Choose a result.", selectionMode: "single" as const, options: [{ id: "yes", label: "Accept" }, { id: "no", label: "Reject" }] }] },
}))).returning();
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
for (const filter of ["touchedByUserId=me", "unreadForUserId=me", "includeAgentCreatedTasks=false&includeParticipatedTasks=true"]) {
const result = await request(app).get(`${path}?${filter}`).expect(200);
expect(result.body.map((issue: { id: string }) => issue.id).sort()).toEqual([ids.question, ids.confirmation].sort());
if (!filter.includes("includeParticipatedTasks")) {
expect(result.body.every((issue: { isUnreadForMe: boolean }) => issue.isUnreadForMe)).toBe(true);
}
}
const svc = issueService(ctx.db);
await svc.markRead(companyId, ids.question!, userId);
const unread = await request(app).get(`${path}?unreadForUserId=me`).expect(200);
expect(unread.body.map((issue: { id: string }) => issue.id)).toEqual([ids.confirmation]);
for (const interaction of requests) await ctx.db.update(issueThreadInteractions).set({ status: "accepted" }).where(eq(issueThreadInteractions.id, interaction.id));
const resolved = await request(app).get(`${path}?touchedByUserId=me`).expect(200);
expect(resolved.body).toEqual([]);
});
it("keeps a service-created request unread for the human assignee until they read it", async () => {
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Assigned request");
await ctx.db.insert(authUsers).values({ id: userId, name: "Recipient", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
const [agent] = await ctx.db.insert(agents).values({ companyId, name: "Requester", role: "engineer", adapterType: "process" }).returning();
const [issue] = await ctx.db.insert(issues).values({
companyId, title: "Assigned channel task", originKind: "chat_channel", status: "todo", assigneeUserId: userId,
createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"),
}).returning();
const svc = issueService(ctx.db);
await svc.markRead(companyId, issue!.id, userId, new Date("2026-01-02"));
const interaction = await issueThreadInteractionService(ctx.db).create(issue!, {
kind: "ask_user_questions", addresseeUserId: userId, resolverPolicy: "human_only",
payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "answer", prompt: "What result do you need?", answerMode: "text", required: true }] } },
}, { agentId: agent!.id });
const [updated] = await ctx.db.select().from(issues).where(eq(issues.id, issue!.id));
expect(updated!.updatedAt.getTime()).toBeGreaterThanOrEqual(new Date(interaction.createdAt).getTime());
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
for (const view of ["", "&view=compact"]) {
const unread = await request(app).get(`${path}?unreadForUserId=me${view}`).expect(200);
expect(unread.body.map((row: { id: string }) => row.id)).toEqual([issue!.id]);
expect(unread.body[0].isUnreadForMe).toBe(true);
}
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(1);
await svc.markRead(companyId, issue!.id, userId);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(0);
const read = await request(app).get(`${path}?touchedByUserId=me`).expect(200);
expect(read.body[0].isUnreadForMe).toBe(false);
});
it.each(["request_checkbox_confirmation", "request_item_verdicts"] as const)("resurfaces archived automatic work for an addressed %s", async (kind) => {
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Archived requests");
await ctx.db.insert(authUsers).values({ id: userId, name: "Recipient", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
const [agent] = await ctx.db.insert(agents).values({ companyId, name: "Requester", role: "engineer", adapterType: "process" }).returning();
const tasks = await ctx.db.insert(issues).values(["addressed", "otherRecipient"].map((title) => ({
companyId, title, originKind: "chat_channel", status: "todo",
createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"),
}))).returning();
const svc = issueService(ctx.db);
for (const task of tasks) {
await svc.archiveInbox(companyId, task.id, userId, new Date("2026-01-02"));
await svc.markRead(companyId, task.id, userId, new Date("2026-01-02"));
}
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
const mineQuery = "touchedByUserId=me&inboxArchivedByUserId=me";
const unreadQuery = "unreadForUserId=me&inboxArchivedByUserId=me";
await request(app).get(`${path}?${mineQuery}`).expect(200).expect([]);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(0);
const input = kind === "request_checkbox_confirmation"
? { kind, payload: { version: 1 as const, prompt: "Select the result.", options: [{ id: "result", label: "Result" }] } }
: { kind, payload: { version: 1 as const, prompt: "Review the result.", items: [{ id: "result", label: "Result" }] } };
await issueThreadInteractionService(ctx.db).create(tasks[0]!, {
...input, addresseeUserId: userId, resolverPolicy: "human_only",
}, { agentId: agent!.id });
await ctx.db.insert(issueThreadInteractions).values({
companyId, issueId: tasks[1]!.id, kind, addresseeUserId: "someone-else",
effectiveResolverPolicy: "human_only", payload: input.payload,
});
for (const view of ["", "&view=compact"]) {
for (const query of [mineQuery, unreadQuery]) {
const result = await request(app).get(`${path}?${query}${view}`).expect(200);
expect(result.body.map((row: { id: string }) => row.id)).toEqual([tasks[0]!.id]);
expect(result.body[0].isUnreadForMe).toBe(true);
}
}
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(1);
await svc.markRead(companyId, tasks[0]!.id, userId);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(0);
const read = await request(app).get(`${path}?${mineQuery}`).expect(200);
expect(read.body.map((row: { id: string }) => row.id)).toEqual([tasks[0]!.id]);
expect(read.body[0].isUnreadForMe).toBe(false);
await svc.archiveInbox(companyId, tasks[0]!.id, userId);
await request(app).get(`${path}?${mineQuery}`).expect(200).expect([]);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(0);
});
it("excludes historical and closed requests while preserving current questions and authorization requests", async () => {
const { companyId, userId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Current requests");
const names = ["historical", "done", "cancelled", "current", "authorization"];
const ids = Object.fromEntries(names.map((name) => [name, randomUUID()]));
await ctx.db.insert(issues).values(names.map((name) => ({
id: ids[name]!, companyId, title: name, originKind: "chat_channel",
status: name === "done" || name === "cancelled" ? name : "todo",
createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"),
})));
await ctx.db.insert(issueThreadInteractions).values(names.map((name) => ({
companyId, issueId: ids[name]!, kind: "ask_user_questions", addresseeUserId: userId,
effectiveResolverPolicy: "human_only" as const, createdAt: new Date("2026-01-02"),
payload: { version: 1 as const, ...(name === "authorization" ? { connectionAuthorization: {} } : {}),
questionSet: { schema: "paperclip.question_set.v1" as const, questions: [{ id: "answer", prompt: "What result do you need?", answerMode: "text" as const, required: true }] } },
})));
await ctx.db.insert(issueComments).values(["historical", "authorization"].map((name) => ({
companyId, issueId: ids[name]!, authorUserId: "another-human", body: "The request has changed.", createdAt: new Date("2026-01-03"),
})));
const app = routeApp(ctx.db, actor, issueRoutes);
const path = `/api/companies/${companyId}/issues`;
for (const filter of ["touchedByUserId=me", "unreadForUserId=me", "includeAgentCreatedTasks=false&includeParticipatedTasks=true"]) {
const result = await request(app).get(`${path}?${filter}`).expect(200);
expect(result.body.map((row: { id: string }) => row.id).sort()).toEqual([ids.current, ids.authorization].sort());
}
expect(await issueService(ctx.db).countUnreadTouchedByUser(companyId, userId)).toBe(2);
});
});
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { deriveIssueUserContext } from "../services/issues.ts";
function makeIssue(overrides?: Partial<{
createdByAgentId: string | null;
originKind: string | null;
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date;
@@ -62,6 +64,18 @@ describe("deriveIssueUserContext", () => {
expect(context.isUnreadForMe).toBe(true);
});
it("excludes automatic creator attribution from the personal touch time", () => {
for (const originKind of ["chat_channel", "routine_execution"]) {
const context = deriveIssueUserContext(
makeIssue({ originKind, createdByUserId: "user-1" }),
"user-1",
{ myLastCommentAt: null, myLastReadAt: null, lastExternalCommentAt: null },
);
expect(context.myLastTouchAt).toBeNull();
expect(context.isUnreadForMe).toBe(false);
}
});
it("uses issue updated time as fallback touch point for assignee", () => {
const context = deriveIssueUserContext(
makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }),
+10 -28
View File
@@ -1333,7 +1333,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
]);
});
it("records the manual board runner on fresh routine issues so they appear in that user's inbox", async () => {
it("retains manual runner attribution without adding fresh routine issues to Mine", async () => {
const { companyId, agentId, issueSvc, routine, svc } = await seedFixture();
const userId = randomUUID();
@@ -1362,7 +1362,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
inboxArchivedByUserId: userId,
includeRoutineExecutions: true,
});
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
expect(inboxIssues.map((issue) => issue.id)).not.toContain(run.linkedIssueId);
});
it("uses the routine revision responsible-user snapshot for automatic runs", async () => {
@@ -1484,7 +1484,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routineIssues[0]?.id).toBe(previousIssue.id);
});
it("touches a coalesced routine issue for the manual runner's inbox", async () => {
it("preserves the archive and Mine membership when a manual run coalesces", async () => {
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
const userId = randomUUID();
const previousRunId = randomUUID();
@@ -1542,29 +1542,20 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.linkedIssueId).toBe(previousIssue.id);
await expect(
db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)),
).resolves.toHaveLength(0);
).resolves.toHaveLength(1);
await expect(
db.select().from(activityLog).where(eq(activityLog.entityId, previousIssue.id)),
).resolves.toEqual([
expect.objectContaining({
companyId,
actorType: "user",
actorId: userId,
action: "issue.inbox_touched",
entityType: "issue",
entityId: previousIssue.id,
}),
]);
).resolves.toEqual([]);
const inboxIssues = await issueSvc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
includeRoutineExecutions: true,
});
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
expect(inboxIssues.map((issue) => issue.id)).not.toContain(previousIssue.id);
});
it("touches a skipped active routine issue for the manual runner's inbox", async () => {
it("preserves the archive and Mine membership when a manual run is skipped", async () => {
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
const userId = randomUUID();
const previousRunId = randomUUID();
@@ -1628,26 +1619,17 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(run.linkedIssueId).toBe(previousIssue.id);
await expect(
db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)),
).resolves.toHaveLength(0);
).resolves.toHaveLength(1);
await expect(
db.select().from(activityLog).where(eq(activityLog.entityId, previousIssue.id)),
).resolves.toEqual([
expect.objectContaining({
companyId,
actorType: "user",
actorId: userId,
action: "issue.inbox_touched",
entityType: "issue",
entityId: previousIssue.id,
}),
]);
).resolves.toEqual([]);
const inboxIssues = await issueSvc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
includeRoutineExecutions: true,
});
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
expect(inboxIssues.map((issue) => issue.id)).not.toContain(previousIssue.id);
});
it("does not coalesce live routine runs with different resolved variables", async () => {
@@ -44,6 +44,18 @@ describe.skipIf(!support.supported)("personal task toast eligibility (persisted
await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: "alice", body: "Following this." });
await onlyAlice(id);
});
it("keeps automatic attribution and imported comments quiet until direct participation", async () => {
for (const originKind of ["chat_channel", "routine_execution"]) {
const id = await task({ originKind, createdByUserId: "alice", responsibleUserId: "alice" });
await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: "alice", body: "Imported reply",
metadata: { version: 1, sourceChannel: "github", sections: [] } });
await db.insert(activityLog).values({ companyId, entityType: "issue", entityId: id, actorType: "user", actorId: "alice",
action: "issue.inbox_touched", details: { source: "manual_routine_run" } });
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
await db.insert(issueComments).values({ companyId, issueId: id, authorUserId: "alice", body: "Direct board reply" });
await onlyAlice(id);
}
});
it("qualifies the first mention and subsequent events, excluding deleted mentions and plain names", async () => {
const id = await task({ description: "alice user://alice" });
expect(await taskToastEligible(db, event(id), "alice")).toBe(false);
+1 -3
View File
@@ -2227,9 +2227,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
authorType: taskUserId ? "user" : "system",
metadata: {
version: 1,
...(endpoint.provider === "imessage-photon"
? { sourceChannel: "imessage-photon" as const }
: {}),
sourceChannel: endpoint.provider,
sections: [
{
title: `${PROVIDER_LABELS[endpoint.provider]} sender`,
+62 -4
View File
@@ -1,5 +1,7 @@
import { sql } from "drizzle-orm";
import { activityLog, issueComments, issues } from "@paperclipai/db";
import { sql, type SQL } from "drizzle-orm";
import { activityLog, chatConversations, chatMessageLinks, issueComments, issues, issueThreadInteractions } from "@paperclipai/db";
import { HUMAN_TASK_ORIGIN_KINDS } from "@paperclipai/shared";
import { activeIssueInteractionCondition } from "./issue-question-context.js";
// Mine participation fails closed. Add new user-authored issue mutation actions
// here instead of admitting every issue activity, because reads, previews, and
@@ -69,11 +71,63 @@ export function automaticReadRecoveryCleanupCondition() {
)`;
}
export function humanCreatedIssueCondition() {
return sql<boolean>`(
${issues.createdByAgentId} IS NULL
AND ${issues.createdByUserId} IS NOT NULL
AND ${issues.originKind} IN (${sql.join(HUMAN_TASK_ORIGIN_KINDS.map((kind) => sql`${kind}`), sql`, `)})
)`;
}
export function directHumanCommentCondition() {
return sql<boolean>`(
${issueComments.authorAgentId} IS NULL
AND ${issueComments.derivedAuthorAgentId} IS NULL
AND ${issueComments.createdByRunId} IS NULL
AND COALESCE(${issueComments.authorType}, 'user') = 'user'
AND COALESCE(${issueComments.metadata}->>'sourceChannel', '') = ''
AND NOT jsonb_path_exists(
COALESCE(${issueComments.metadata}, '{}'::jsonb),
'$.sections[*] ? (@.title like_regex " sender$" && exists(@.rows[*] ? (@.type == "key_value" && @.label == "Provider ID")))'
)
AND NOT EXISTS (
SELECT 1 FROM ${chatConversations}
INNER JOIN ${chatMessageLinks}
ON ${chatMessageLinks.companyId} = ${chatConversations.companyId}
AND ${chatMessageLinks.endpointId} = ${chatConversations.endpointId}
AND ${chatMessageLinks.conversationId} = ${chatConversations.id}
WHERE ${chatConversations.companyId} = ${issueComments.companyId}
AND ${chatConversations.issueId} = ${issueComments.issueId}
AND ${chatMessageLinks.commentId} = ${issueComments.id}
AND ${chatMessageLinks.direction} = 'inbound'
)
)`;
}
export function pendingUserRequestCondition(companyId: string, userId: string, after?: SQL) {
return sql<boolean>`EXISTS (
SELECT 1 FROM ${issueThreadInteractions}
WHERE ${issueThreadInteractions.companyId} = ${companyId}
AND ${issueThreadInteractions.issueId} = ${issues.id}
AND ${issueThreadInteractions.status} = 'pending'
AND ${issues.status} NOT IN ('done', 'cancelled')
AND ${activeIssueInteractionCondition()}
AND ${issueThreadInteractions.addresseeUserId} = ${userId}
AND ${issueThreadInteractions.addresseeAgentId} IS NULL
AND (
${issueThreadInteractions.effectiveResolverPolicy} <> 'not_creator'
OR ${issueThreadInteractions.createdByUserId} IS DISTINCT FROM ${userId}
)
${after ? sql`AND ${issueThreadInteractions.createdAt} > ${after}` : sql``}
)`;
}
export function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
${issues.createdByUserId} = ${userId}
(${issues.createdByUserId} = ${userId} AND ${humanCreatedIssueCondition()})
OR ${issues.assigneeUserId} = ${userId}
OR ${pendingUserRequestCondition(companyId, userId)}
OR EXISTS (
SELECT 1
FROM ${activityLog}
@@ -83,6 +137,10 @@ export function touchedByUserCondition(companyId: string, userId: string) {
AND ${activityLog.actorType} = 'user'
AND ${activityLog.actorId} = ${userId}
AND NOT ${automaticReadRecoveryCleanupCondition()}
AND NOT (
${activityLog.action} = 'issue.inbox_touched'
AND COALESCE(${activityLog.details}->>'source', '') = 'manual_routine_run'
)
AND ${activityLog.action} IN (${sql.join(
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
@@ -96,8 +154,8 @@ export function touchedByUserCondition(companyId: string, userId: string) {
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorUserId} = ${userId}
AND ${directHumanCommentCondition()}
)
)
`;
}
+38 -17
View File
@@ -1,8 +1,7 @@
import { measureRunTransaction } from "./execution-transaction-timing.js";
import { assertNoCaseHandoffMutation, cancelCaseHandoffsForIssue, completeTerminalCaseHandoffsForIssue } from "./heartbeat/case-handoff-lifecycle.js";
import { measureRunPhase } from "./execution-preparation-timing.js";
import { automaticReadRecoveryCleanupCondition, touchedByUserCondition } from "./issue-participation.js";
import { HUMAN_TASK_ORIGIN_KINDS } from "@paperclipai/shared";
import { automaticReadRecoveryCleanupCondition, directHumanCommentCondition, humanCreatedIssueCondition, pendingUserRequestCondition, touchedByUserCondition } from "./issue-participation.js";
import { resolveProjectDefaultWorkspaceId } from "./project-default-workspace.js";
import { type AuthorizationActor, executionWorkspaceReadSqlCondition, projectReadSqlCondition } from "./authorization.js";
import { assertExecutionWorkspaceRecordAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
@@ -120,6 +119,7 @@ import {
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
isAgentCreatedIssue,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
} from "@paperclipai/shared";
@@ -1900,6 +1900,7 @@ type IssueWithLabelsAndRun = IssueWithLabels & {
};
type IssueUserCommentStats = {
issueId: string;
isUnreadForMe: boolean;
myLastCommentAt: Date | null;
lastExternalCommentAt: Date | null;
};
@@ -1939,6 +1940,8 @@ function serializeAcceptedPlanDecomposition(
};
}
type IssueUserContextInput = {
createdByAgentId?: string | null;
originKind?: string | null;
createdByUserId: string | null;
assigneeUserId: string | null;
createdAt: Date | string;
@@ -2828,6 +2831,7 @@ function myLastCommentAtExpr(companyId: string, userId: string) {
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.authorUserId} = ${userId}
AND ${directHumanCommentCondition()}
)
`;
}
@@ -2851,7 +2855,7 @@ function myLastTouchAtExpr(companyId: string, userId: string) {
GREATEST(
COALESCE(${myLastCommentAt}, to_timestamp(0)),
COALESCE(${myLastReadAt}, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} AND ${humanCreatedIssueCondition()} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)),
COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0))
)
`;
@@ -2907,10 +2911,14 @@ function issueCanonicalLastActivityAtExpr(companyId: string) {
function unreadForUserCondition(companyId: string, userId: string) {
const touchedCondition = touchedByUserCondition(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
const myLastRequestReadAt = sql`GREATEST(
COALESCE(${myLastCommentAtExpr(companyId, userId)}, to_timestamp(0)),
COALESCE(${myLastReadAtExpr(companyId, userId)}, to_timestamp(0))
)`;
return sql<boolean>`
(
${touchedCondition}
AND EXISTS (
AND (EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
@@ -2920,7 +2928,7 @@ function unreadForUserCondition(companyId: string, userId: string) {
OR ${issueComments.authorUserId} <> ${userId}
)
AND ${issueComments.createdAt} > ${myLastTouchAt}
)
) OR ${pendingUserRequestCondition(companyId, userId, myLastRequestReadAt)})
)
`;
}
@@ -2946,6 +2954,7 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) {
)
AND ${issueThreadInteractions.createdAt} > ${issueInboxArchives.archivedAt}
)
OR ${pendingUserRequestCondition(companyId, userId, sql`${issueInboxArchives.archivedAt}`)}
OR EXISTS (
SELECT 1
FROM ${activityLog}
@@ -3014,6 +3023,7 @@ export function deriveIssueUserContext(
userId: string,
stats:
| {
isUnreadForMe?: boolean;
myLastCommentAt: Date | string | null;
myLastReadAt: Date | string | null;
lastExternalCommentAt: Date | string | null;
@@ -3032,7 +3042,9 @@ export function deriveIssueUserContext(
const myLastCommentAt = normalizeDate(stats?.myLastCommentAt);
const myLastReadAt = normalizeDate(stats?.myLastReadAt);
const createdTouchAt =
issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null;
issue.createdByUserId === userId && !isAgentCreatedIssue(issue)
? normalizeDate(issue.createdAt)
: null;
const assignedTouchAt =
issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null;
const myLastTouchAt =
@@ -3040,7 +3052,7 @@ export function deriveIssueUserContext(
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null;
const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt);
const isUnreadForMe = Boolean(
const isUnreadForMe = stats?.isUnreadForMe ?? Boolean(
myLastTouchAt &&
lastExternalCommentAt &&
lastExternalCommentAt.getTime() > myLastTouchAt.getTime(),
@@ -4974,7 +4986,8 @@ async function userCommentStatsForIssues(
.select({
issueId: issueComments.issueId,
myLastCommentAt: sql<Date | null>`
MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} THEN ${issueComments.createdAt} END)
MAX(CASE WHEN ${issueComments.authorUserId} = ${userId} AND ${directHumanCommentCondition()}
THEN ${issueComments.createdAt} END)
`,
lastExternalCommentAt: sql<Date | null>`
MAX(
@@ -4993,7 +5006,21 @@ async function userCommentStatsForIssues(
),
)
.groupBy(issueComments.issueId);
stats.push(...rows);
const unreadRows = await dbOrTx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, companyId),
inArray(issues.id, issueIdChunk),
unreadForUserCondition(companyId, userId),
));
const unreadIds = new Set(unreadRows.map((row: { id: string }) => row.id));
const commentsByIssueId = new Map<string, { myLastCommentAt: Date | null; lastExternalCommentAt: Date | null }>(
rows.map((row: { issueId: string; myLastCommentAt: Date | null; lastExternalCommentAt: Date | null }) => [row.issueId, row]),
);
for (const issueId of issueIdChunk) stats.push({
issueId,
myLastCommentAt: commentsByIssueId.get(issueId)?.myLastCommentAt ?? null,
lastExternalCommentAt: commentsByIssueId.get(issueId)?.lastExternalCommentAt ?? null,
isUnreadForMe: unreadIds.has(issueId),
});
}
return stats;
}
@@ -6207,14 +6234,6 @@ function assertValidAssigneeAgentFilter(
}
}
function humanCreatedIssueCondition() {
return and(
isNull(issues.createdByAgentId),
isNotNull(issues.createdByUserId),
inArray(issues.originKind, [...HUMAN_TASK_ORIGIN_KINDS]),
)!;
}
function creationVisibilityCondition(companyId: string, filters?: IssueFilters) {
if (filters?.includeAgentCreatedTasks !== false) return undefined;
return filters.creationVisibilityUserId
@@ -6509,6 +6528,7 @@ async function listBlockedInboxIssues(
: {}),
...(contextUserId
? deriveIssueUserContext(row, contextUserId, {
isUnreadForMe: statsByIssueId.get(row.id)?.isUnreadForMe ?? false,
myLastCommentAt:
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
@@ -8311,6 +8331,7 @@ export function issueService(db: Db) {
}
: {}),
...deriveIssueUserContext(row, contextUserId, {
isUnreadForMe: statsByIssueId.get(row.id)?.isUnreadForMe ?? false,
myLastCommentAt:
statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
-49
View File
@@ -18,7 +18,6 @@ import {
folders,
goals,
heartbeatRuns,
issueInboxArchives,
issues,
pluginManagedResources,
plugins,
@@ -1695,38 +1694,6 @@ export function routineService(
return value;
}
async function touchIssueForUserInbox(
executor: Db,
input: {
companyId: string;
issueId: string;
userId: string;
touchedAt: Date;
},
) {
await executor.insert(activityLog).values({
companyId: input.companyId,
actorType: "user",
actorId: input.userId,
action: "issue.inbox_touched",
entityType: "issue",
entityId: input.issueId,
responsibleUserId: input.userId,
details: { source: "manual_routine_run" },
createdAt: input.touchedAt,
});
await executor
.delete(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, input.companyId),
eq(issueInboxArchives.issueId, input.issueId),
eq(issueInboxArchives.userId, input.userId),
),
);
}
async function dispatchRoutineRun(input: {
routine: typeof routines.$inferSelect;
trigger: typeof routineTriggers.$inferSelect | null;
@@ -1896,14 +1863,6 @@ export function routineService(
});
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
await touchIssueForUserInbox(txDb, {
companyId: input.routine.companyId,
issueId: activeIssue.id,
userId: manualRunnerUserId,
touchedAt: triggeredAt,
});
}
const updated = await finalizeRun(createdRun.id, {
status,
linkedIssueId: activeIssue.id,
@@ -1963,14 +1922,6 @@ export function routineService(
});
if (!existingIssue) throw error;
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
await touchIssueForUserInbox(txDb, {
companyId: input.routine.companyId,
issueId: existingIssue.id,
userId: manualRunnerUserId,
touchedAt: triggeredAt,
});
}
const updated = await finalizeRun(createdRun.id, {
status,
linkedIssueId: existingIssue.id,