From 02c779b41d6fa8aa06f01fa5f4564b5eaae9126a Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 21 Mar 2026 12:20:48 -0500 Subject: [PATCH] Use issue participation for agent history Co-Authored-By: Paperclip --- server/src/__tests__/issues-service.test.ts | 284 ++++++++++++++++++++ server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 29 ++ ui/src/api/issues.ts | 2 + ui/src/components/IssuesList.tsx | 11 +- ui/src/pages/AgentDetail.tsx | 14 +- ui/src/pages/Issues.tsx | 6 +- 7 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/issues-service.test.ts diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts new file mode 100644 index 000000000..70b99ba8f --- /dev/null +++ b/server/src/__tests__/issues-service.test.ts @@ -0,0 +1,284 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + applyPendingMigrations, + companies, + createDb, + ensurePostgresDatabase, + issueComments, + issues, +} from "@paperclipai/db"; +import { issueService } from "../services/issues.ts"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, dataDir, instance }; +} + +describe("issueService.list participantAgentId", () => { + let db!: ReturnType; + let svc!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + svc = issueService(db); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("returns issues an agent participated in across the supported signals", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "OtherAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const assignedIssueId = randomUUID(); + const createdIssueId = randomUUID(); + const commentedIssueId = randomUUID(); + const activityIssueId = randomUUID(); + const excludedIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + createdByAgentId: otherAgentId, + }, + { + id: createdIssueId, + companyId, + title: "Created issue", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: commentedIssueId, + companyId, + title: "Commented issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: activityIssueId, + companyId, + title: "Activity issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: excludedIssueId, + companyId, + title: "Excluded issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + assigneeAgentId: otherAgentId, + }, + ]); + + await db.insert(issueComments).values({ + companyId, + issueId: commentedIssueId, + authorAgentId: agentId, + body: "Investigating this issue.", + }); + + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: agentId, + action: "issue.updated", + entityType: "issue", + entityId: activityIssueId, + agentId, + details: { changed: true }, + }); + + const result = await svc.list(companyId, { participantAgentId: agentId }); + const resultIds = new Set(result.map((issue) => issue.id)); + + expect(resultIds).toEqual(new Set([ + assignedIssueId, + createdIssueId, + commentedIssueId, + activityIssueId, + ])); + expect(resultIds.has(excludedIssueId)).toBe(false); + }); + + it("combines participation filtering with search", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const matchedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: matchedIssueId, + companyId, + title: "Invoice reconciliation", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: otherIssueId, + companyId, + title: "Weekly planning", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + ]); + + const result = await svc.list(companyId, { + participantAgentId: agentId, + q: "invoice", + }); + + expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 43eebe66d..073b32f79 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) { const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, + participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, unreadForUserId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 681da27d7..ae377388c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,6 +1,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + activityLog, agents, assets, companies, @@ -62,6 +63,7 @@ function applyStatusSideEffects( export interface IssueFilters { status?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -134,6 +136,30 @@ function touchedByUserCondition(companyId: string, userId: string) { `; } +function participatedByAgentCondition(companyId: string, agentId: string) { + return sql` + ( + ${issues.createdByAgentId} = ${agentId} + OR ${issues.assigneeAgentId} = ${agentId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorAgentId} = ${agentId} + ) + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} = ${issues.id}::text + AND ${activityLog.agentId} = ${agentId} + ) + ) + `; +} + function myLastCommentAtExpr(companyId: string, userId: string) { return sql` ( @@ -508,6 +534,9 @@ export function issueService(db: Db) { if (filters?.assigneeAgentId) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } + if (filters?.participantAgentId) { + conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); + } if (filters?.assigneeUserId) { conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); } diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 308028b36..62cb347cf 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -18,6 +18,7 @@ export const issuesApi = { status?: string; projectId?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -32,6 +33,7 @@ export const issuesApi = { if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); + if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 2b44a2f2d..e04157b84 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -166,6 +166,9 @@ interface IssuesListProps { issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; + searchFilters?: { + participantAgentId?: string; + }; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -182,6 +185,7 @@ export function IssuesList({ issueLinkState, initialAssignees, initialSearch, + searchFilters, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -239,8 +243,11 @@ export function IssuesList({ }, [scopedKey]); const { data: searchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), + searchFilters ?? {}, + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2c5297d0c..54c0e03fa 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -572,9 +572,9 @@ export function AgentDetail() { }); const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(resolvedCompanyId!), - queryFn: () => issuesApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId && needsDashboardData, + queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }), + enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData, }); const { data: allAgents } = useQuery({ @@ -592,7 +592,6 @@ export function AgentDetail() { }); const assignedIssues = (allIssues ?? []) - .filter((i) => i.assigneeAgentId === agent?.id) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); @@ -1174,12 +1173,15 @@ function AgentOverview({

Recent Issues

- + See All →
{assignedIssues.length === 0 ? ( -

No assigned issues.

+

No recent issues.

) : (
{assignedIssues.slice(0, 10).map((issue) => ( diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index bc5131e78..ee3d64b09 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -21,6 +21,7 @@ export function Issues() { const queryClient = useQueryClient(); const initialSearch = searchParams.get("q") ?? ""; + const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const debounceRef = useRef>(undefined); const handleSearchChange = useCallback((search: string) => { clearTimeout(debounceRef.current); @@ -86,8 +87,8 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), enabled: !!selectedCompanyId, }); @@ -117,6 +118,7 @@ export function Issues() { initialSearch={initialSearch} onSearchChange={handleSearchChange} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} + searchFilters={participantAgentId ? { participantAgentId } : undefined} /> ); }