Add triage assistant backend

This commit is contained in:
Dotta committed 2026-05-19 13:28:07 -05:00
1 parent 929a275717
commit ac27ec06ca
5 files changed
+1950 -2

No files matched your search

@@ -67,6 +67,10 @@ CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_chats (
CREATE INDEX triage_queue_chats_company_queue_idx
ON plugin_triage_f3b4aa721e.triage_queue_chats (company_id, queue_id, updated_at DESC);
CREATE UNIQUE INDEX triage_queue_chats_active_queue_idx
ON plugin_triage_f3b4aa721e.triage_queue_chats (queue_id)
WHERE status = 'active';
CREATE TABLE plugin_triage_f3b4aa721e.triage_items (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
@@ -69,6 +69,16 @@ const manifest: PaperclipPluginManifestV1 = {
"database.namespace.read",
"database.namespace.write",
"companies.read",
"issues.read",
"issues.create",
"issues.update",
"issue.comments.read",
"issue.comments.create",
"agents.read",
"agents.resume",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
"agents.managed",
"projects.managed",
"skills.managed",
File diff suppressed because it is too large. Load diff
@@ -4,11 +4,13 @@ import {
type PluginContext,
type PluginApiRequestInput,
type PaperclipPlugin,
type AgentSession,
type PluginManagedAgentResolution,
type PluginManagedProjectResolution,
type PluginManagedSkillResolution,
} from "@paperclipai/plugin-sdk";
import {
PLUGIN_ID,
TRIAGE_ASSISTANT_AGENT_KEY,
TRIAGE_MANAGED_SKILL_KEYS,
TRIAGE_PROJECT_KEY,
@@ -140,6 +142,66 @@ async function managedResourceHealth(
};
}
function actionActor(ctx: PluginContext, params: Record<string, unknown>) {
return {
actorType: "plugin-action",
actorId: ctx.manifest.id,
actorRunId: typeof params.actorRunId === "string" ? params.actorRunId : null,
};
}
async function ensureQueueChatIssue(
ctx: PluginContext,
service: ReturnType<typeof createTriageService>,
params: Record<string, unknown>,
) {
const companyId = requireCompanyId(params);
const ensured = await service.ensureQueueChat(params);
let hiddenIssueId = ensured.chat.hiddenIssueId;
const existingIssue = hiddenIssueId ? await ctx.issues.get(hiddenIssueId, companyId) : null;
if (!existingIssue) {
const project = await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId);
const issue = await ctx.issues.create({
companyId,
projectId: project.projectId ?? undefined,
title: ensured.chat.title ?? `Triage chat: ${ensured.queue.title}`,
description: [
`Hidden queue chat for Paperclip Triage queue \`${ensured.queue.queueKey}\`.`,
"",
"This issue stores assistant conversation audit context for the queue and is created by the triage plugin.",
].join("\n"),
status: "in_review",
priority: "low",
surfaceVisibility: "plugin_operation",
originKind: `plugin:${PLUGIN_ID}:operation:queue-chat`,
originId: `queue-chat:${ensured.queue.id}`,
});
hiddenIssueId = issue.id;
ensured.chat = await service.updateQueueChat({
companyId,
chatId: ensured.chat.id,
hiddenIssueId,
metadata: {
...ensured.chat.metadata,
hiddenIssueIdentifier: issue.identifier,
},
});
}
return { ...ensured, hiddenIssueId };
}
async function activeSessionForChat(
ctx: PluginContext,
companyId: string,
agentId: string,
chatMetadata: Record<string, unknown>,
): Promise<AgentSession | null> {
const activeSessionId = typeof chatMetadata.activeSessionId === "string" ? chatMetadata.activeSessionId : null;
if (!activeSessionId) return null;
const sessions = await ctx.agents.sessions.list(agentId, companyId);
return sessions.find((session) => session.sessionId === activeSessionId) ?? null;
}
export function createTriagePlugin(options: {
createStore?: (ctx: PluginContext) => TriageStore;
} = {}): PaperclipPlugin {
@@ -235,6 +297,26 @@ export function createTriagePlugin(options: {
return getService().getItem(params);
});
ctx.data.register("assistant-context", async (params: Record<string, unknown>) => {
return getService().getAssistantContext(params);
});
ctx.data.register("queue-guidance", async (params: Record<string, unknown>) => {
return getService().listGuidanceDocs(params);
});
ctx.data.register("guidance-proposals", async (params: Record<string, unknown>) => {
return getService().listGuidanceProposals(params);
});
ctx.data.register("item-events", async (params: Record<string, unknown>) => {
return getService().listItemEvents(params);
});
ctx.data.register("queue-transition-actions", async (params: Record<string, unknown>) => {
return getService().listTransitionActions(params);
});
ctx.actions.register("reconcile-managed-resources", async (params: Record<string, unknown>) => {
const companyId = requireCompanyId(params);
const result = await managedResourceHealth(ctx, companyId, "reconcile");
@@ -277,10 +359,117 @@ export function createTriagePlugin(options: {
return getService().updateItem(params);
});
ctx.actions.register("update-item-content", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().updateItemContent(params, actionActor(ctx, params));
});
ctx.actions.register("archive-item", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().archiveItem(params);
});
ctx.actions.register("upsert-transition-action", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().upsertTransitionAction(params);
});
ctx.actions.register("transition-item", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().transitionItem(params, ctx, actionActor(ctx, params));
});
ctx.actions.register("ensure-queue-chat", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return ensureQueueChatIssue(ctx, getService(), params);
});
ctx.actions.register("send-assistant-message", async (params: Record<string, unknown>) => {
const companyId = requireCompanyId(params);
await requireKnownCompany(ctx, companyId);
const message = stringField(params.message);
if (!message) {
throw new Error("message is required");
}
const context = await getService().getAssistantContext(params);
const chat = await ensureQueueChatIssue(ctx, getService(), params);
const agentResolution = await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId);
if (!agentResolution.agentId) {
throw new Error("Triage Assistant managed agent is missing");
}
if (agentResolution.agent?.status === "paused") {
await ctx.agents.resume(agentResolution.agentId, companyId);
}
const existingSession = await activeSessionForChat(
ctx,
companyId,
agentResolution.agentId,
chat.chat.metadata,
);
const session = existingSession ??
await ctx.agents.sessions.create(agentResolution.agentId, companyId, {
taskKey: `plugin:${PLUGIN_ID}:session:triage-queue-chat:${chat.queue.id}`,
reason: "triage_queue_chat",
});
const run = await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt: context.prompt,
reason: `triage:${chat.queue.queueKey}`,
});
const updatedChat = await getService().updateQueueChat({
companyId,
chatId: chat.chat.id,
metadata: {
...chat.chat.metadata,
activeSessionId: session.sessionId,
lastRunId: run.runId,
lastItemId: context.item.id,
},
});
if (chat.hiddenIssueId) {
await ctx.issues.createComment(
chat.hiddenIssueId,
[`User message for item \`${context.item.title}\`:`, "", message].join("\n"),
companyId,
{ authorAgentId: agentResolution.agentId },
);
}
return {
queue: context.queue,
item: context.item,
guidanceDocs: context.guidanceDocs,
chat: updatedChat,
hiddenIssueId: chat.hiddenIssueId,
session,
runId: run.runId,
prompt: context.prompt,
};
});
ctx.actions.register("generate-guidance-proposal", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().createGuidanceProposal(params, actionActor(ctx, params));
});
ctx.actions.register("revise-guidance-proposal", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().reviseGuidanceProposal(params, actionActor(ctx, params));
});
ctx.actions.register("reject-guidance-proposal", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().rejectGuidanceProposal(params, actionActor(ctx, params));
});
ctx.actions.register("accept-guidance-proposal", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().acceptGuidanceProposal(params, actionActor(ctx, params));
});
ctx.actions.register("manual-edit-guidance", async (params: Record<string, unknown>) => {
await requireKnownCompany(ctx, requireCompanyId(params));
return getService().manualEditGuidance(params, actionActor(ctx, params));
});
},
async onApiRequest(input: PluginApiRequestInput) {
@@ -65,6 +65,11 @@ describe("Paperclip Triage scaffold", () => {
"database.namespace.read",
"database.namespace.write",
"companies.read",
"issues.read",
"issues.create",
"issues.update",
"issue.comments.read",
"issue.comments.create",
"agents.managed",
"projects.managed",
"skills.managed",
@@ -155,7 +160,7 @@ describe("Paperclip Triage queue and item ingest", () => {
const testPlugin = createTriagePlugin({ createStore: () => store });
const harness = createTestHarness({ manifest });
harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] });
return { harness, plugin: testPlugin };
return { harness, plugin: testPlugin, store };
}
it("creates and updates queues through worker actions", async () => {
@@ -304,4 +309,201 @@ describe("Paperclip Triage queue and item ingest", () => {
},
});
});
it("reuses one hidden queue chat and assistant session per queue while isolating other queues", async () => {
const { harness, plugin: testPlugin } = createHarness();
await testPlugin.definition.setup(harness.ctx);
const first = await harness.performAction<{
item: { id: string };
}>("ingest-item", {
companyId: COMPANY_ID,
queueKey: "reviews",
title: "Launch review",
content: "Please review the launch copy.",
properties: { sourceKind: "manual" },
});
const other = await harness.performAction<{
item: { id: string };
}>("ingest-item", {
companyId: COMPANY_ID,
queueKey: "support",
title: "Support review",
content: "Please review the support reply.",
});
const firstSend = await harness.performAction<{
chat: { id: string; hiddenIssueId: string; metadata: Record<string, unknown> };
hiddenIssueId: string;
session: { sessionId: string };
runId: string;
prompt: string;
}>("send-assistant-message", {
companyId: COMPANY_ID,
itemId: first.item.id,
message: "What should I change?",
});
expect(firstSend.hiddenIssueId).toBeTruthy();
expect(firstSend.chat.hiddenIssueId).toBe(firstSend.hiddenIssueId);
expect(firstSend.runId).toBeTruthy();
expect(firstSend.prompt).toContain("Queue: Reviews (reviews)");
expect(firstSend.prompt).toContain("Please review the launch copy.");
expect(firstSend.prompt).toContain("guidance.md");
expect(firstSend.prompt).toContain("What should I change?");
const secondSend = await harness.performAction<{
chat: { id: string; hiddenIssueId: string };
hiddenIssueId: string;
session: { sessionId: string };
}>("send-assistant-message", {
companyId: COMPANY_ID,
itemId: first.item.id,
message: "Use the same thread.",
});
expect(secondSend.chat.id).toBe(firstSend.chat.id);
expect(secondSend.hiddenIssueId).toBe(firstSend.hiddenIssueId);
expect(secondSend.session.sessionId).toBe(firstSend.session.sessionId);
const otherSend = await harness.performAction<{
chat: { id: string };
hiddenIssueId: string;
}>("send-assistant-message", {
companyId: COMPANY_ID,
itemId: other.item.id,
message: "Different queue.",
});
expect(otherSend.chat.id).not.toBe(firstSend.chat.id);
expect(otherSend.hiddenIssueId).not.toBe(firstSend.hiddenIssueId);
});
it("updates item content only when the expected revision matches", async () => {
const { harness, plugin: testPlugin } = createHarness();
await testPlugin.definition.setup(harness.ctx);
const ingested = await harness.performAction<{
item: { id: string; revision: number };
}>("ingest-item", {
companyId: COMPANY_ID,
queueKey: "reviews",
title: "Original",
content: "Original content",
});
const updated = await harness.performAction<{
item: { title: string; content: string; revision: number };
event: { eventType: string; metadata: Record<string, unknown> };
}>("update-item-content", {
companyId: COMPANY_ID,
itemId: ingested.item.id,
expectedRevision: ingested.item.revision,
title: "Edited",
content: "Edited content",
});
expect(updated.item).toMatchObject({ title: "Edited", content: "Edited content", revision: 2 });
expect(updated.event).toMatchObject({
eventType: "item.content.updated",
metadata: { previousRevision: 1, nextRevision: 2 },
});
await expect(harness.performAction("update-item-content", {
companyId: COMPANY_ID,
itemId: ingested.item.id,
expectedRevision: 1,
content: "Stale edit",
})).rejects.toMatchObject({
status: 409,
code: "item_revision_conflict",
});
});
it("supports guidance proposal generation, revision, acceptance, rejection, and manual edits", async () => {
const { harness, plugin: testPlugin } = createHarness();
await testPlugin.definition.setup(harness.ctx);
const ingested = await harness.performAction<{
item: { id: string };
}>("ingest-item", {
companyId: COMPANY_ID,
queueKey: "reviews",
title: "Review example",
content: "Prefer concise copy.",
});
const manual = await harness.performAction<{
doc: { content: string; currentRevisionId: string };
revision: { id: string; summary: string };
}>("manual-edit-guidance", {
companyId: COMPANY_ID,
queueKey: "reviews",
content: "# Reviews Guidance\n\nStart from the user's target audience.",
summary: "Seed guidance",
});
expect(manual.doc.content).toContain("target audience");
expect(manual.doc.currentRevisionId).toBe(manual.revision.id);
const generated = await harness.performAction<{
proposal: { id: string; status: string; proposedContent: string };
}>("generate-guidance-proposal", {
companyId: COMPANY_ID,
itemId: ingested.item.id,
suggestedChange: "For launch copy, flag vague benefits before approving.",
rationale: "The item exposed a repeatable approval rule.",
});
expect(generated.proposal.status).toBe("proposed");
expect(generated.proposal.proposedContent).toContain("target audience");
expect(generated.proposal.proposedContent).toContain("flag vague benefits");
const revised = await harness.performAction<{ status: string; proposedContent: string }>(
"revise-guidance-proposal",
{
companyId: COMPANY_ID,
proposalId: generated.proposal.id,
proposedContent: "# Reviews Guidance\n\nFlag vague benefits and ask for concrete proof.",
rationale: "Sharper wording.",
},
);
expect(revised).toMatchObject({ status: "revised" });
const accepted = await harness.performAction<{
proposal: { status: string; metadata: Record<string, unknown> };
doc: { content: string; currentRevisionId: string };
revision: { id: string };
}>("accept-guidance-proposal", {
companyId: COMPANY_ID,
proposalId: generated.proposal.id,
});
expect(accepted.proposal.status).toBe("accepted");
expect(accepted.proposal.metadata.acceptedRevisionId).toBe(accepted.revision.id);
expect(accepted.doc.content).toContain("concrete proof");
expect(accepted.doc.currentRevisionId).toBe(accepted.revision.id);
const rejectedCandidate = await harness.performAction<{
proposal: { id: string };
}>("generate-guidance-proposal", {
companyId: COMPANY_ID,
itemId: ingested.item.id,
proposedContent: "# Reviews Guidance\n\nToo broad.",
});
const rejected = await harness.performAction<{ status: string; metadata: Record<string, unknown> }>(
"reject-guidance-proposal",
{
companyId: COMPANY_ID,
proposalId: rejectedCandidate.proposal.id,
reason: "Too broad for this queue.",
},
);
expect(rejected).toMatchObject({
status: "rejected",
metadata: expect.objectContaining({ rejectionReason: "Too broad for this queue." }),
});
await expect(harness.performAction("accept-guidance-proposal", {
companyId: COMPANY_ID,
proposalId: rejectedCandidate.proposal.id,
})).rejects.toMatchObject({
status: 409,
code: "guidance_proposal_rejected",
});
});
});