Use issue participation for agent history

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-21 12:20:48 -05:00
parent 5a1e17f27f
commit 02c779b41d
7 changed files with 337 additions and 10 deletions

View File

@@ -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<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
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<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
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<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
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]);
});
});

View File

@@ -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,

View File

@@ -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<boolean>`
(
${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<Date | null>`
(
@@ -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));
}

View File

@@ -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);

View File

@@ -166,6 +166,9 @@ interface IssuesListProps {
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => 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,
});

View File

@@ -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({
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Recent Issues</h3>
<Link to={`/issues?assignee=${agentId}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
<Link
to={`/issues?participantAgentId=${agentId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
See All &rarr;
</Link>
</div>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
<p className="text-sm text-muted-foreground">No recent issues.</p>
) : (
<div className="border border-border rounded-lg">
{assignedIssues.slice(0, 10).map((issue) => (

View File

@@ -21,6 +21,7 @@ export function Issues() {
const queryClient = useQueryClient();
const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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}
/>
);
}