mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Implement selected-agent issue chat backend
This commit is contained in:
1 parent
2d51920655
commit
e26ae06035
7 files changed
+711
-12
No files matched your search
@@ -264,6 +264,7 @@ export {
|
||||
issueCommentMetadataSectionSchema,
|
||||
issueCommentMetadataSchema,
|
||||
addIssueCommentSchema,
|
||||
selectedAgentChatCommentSchema,
|
||||
issueThreadInteractionStatusSchema,
|
||||
issueThreadInteractionKindSchema,
|
||||
issueThreadInteractionContinuationPolicySchema,
|
||||
|
||||
@@ -548,6 +548,12 @@ export const addIssueCommentSchema = z.object({
|
||||
|
||||
export type AddIssueComment = z.infer<typeof addIssueCommentSchema>;
|
||||
|
||||
export const selectedAgentChatCommentSchema = addIssueCommentSchema.extend({
|
||||
targetAgentId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SelectedAgentChatComment = z.infer<typeof selectedAgentChatCommentSchema>;
|
||||
|
||||
export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES);
|
||||
export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS);
|
||||
export const issueThreadInteractionContinuationPolicySchema = z.enum(
|
||||
|
||||
@@ -20,6 +20,13 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
@@ -53,12 +60,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})),
|
||||
decide: mockAccessDecide,
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
approvalService: () => ({}),
|
||||
@@ -171,6 +173,9 @@ describe("agent live run routes", () => {
|
||||
companyId: "company-1",
|
||||
executionRunId: "run-1",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
status: "in_progress",
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue(null);
|
||||
@@ -303,6 +308,69 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("filters issue active-run polling by selected targetAgentId", async () => {
|
||||
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
|
||||
id: "run-assignee",
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "callback",
|
||||
startedAt: new Date("2026-04-10T10:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-04-10T09:59:00.000Z"),
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
});
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue({
|
||||
id: "run-target",
|
||||
status: "running",
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
startedAt: new Date("2026-04-10T10:01:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-04-10T10:00:59.000Z"),
|
||||
agentId: routeAgentId,
|
||||
issueId: "issue-1",
|
||||
});
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: routeAgentId,
|
||||
companyId: "company-1",
|
||||
name: "Selected Agent",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get(`/api/issues/PC1A2-1295/active-run?targetAgentId=${routeAgentId}`),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith(routeAgentId);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-target",
|
||||
issueId: "issue-1",
|
||||
agentId: routeAgentId,
|
||||
agentName: "Selected Agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces issue read authorization before active-run polling", async () => {
|
||||
mockAccessDecide.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
action: "issue:read",
|
||||
reason: "deny_low_trust_boundary",
|
||||
explanation: "Issue is outside this low-trust boundary.",
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Issue is outside this actor's authorization boundary" });
|
||||
expect(mockHeartbeatService.getRunIssueSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses narrow run log metadata lookups for log polling", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ISSUE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const COMPANY_ID = "company-1";
|
||||
const CEO_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const TARGET_AGENT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const ASSIGNEE_AGENT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockIssueReferenceService = vi.hoisted(() => ({
|
||||
syncComment: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string }) => ({
|
||||
allowed: true,
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
})));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
decide: mockAccessDecide,
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
|
||||
ambiguous: false,
|
||||
agent: { id: raw },
|
||||
})),
|
||||
}),
|
||||
companySearchService: () => ({}),
|
||||
documentAnnotationService: () => ({}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
getGeneral: vi.fn(async () => ({
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => [COMPANY_ID]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueReferenceService: () => mockIssueReferenceService,
|
||||
issueRecoveryActionService: () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
issueThreadInteractionService: () => ({
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
}),
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: ISSUE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
parentId: null,
|
||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||
assigneeUserId: null,
|
||||
identifier: "PAP-999",
|
||||
title: "Selected chat",
|
||||
executionRunId: null,
|
||||
executionPolicy: null,
|
||||
billingCode: "engineering",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: TARGET_AGENT_ID,
|
||||
companyId: COMPANY_ID,
|
||||
name: "Target",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function thenableRows(rows: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
then: (resolve: (value: Array<Record<string, unknown>>) => unknown, reject?: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve, reject),
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(...selectRows: Array<Array<Record<string, unknown>>>) {
|
||||
const rowsQueue = [...selectRows];
|
||||
return {
|
||||
select: vi.fn(() => thenableRows(rowsQueue.shift() ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
async function createApp(db: Record<string, unknown>) {
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: [COMPANY_ID],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("selected-agent issue chat backend", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
vi.clearAllMocks();
|
||||
mockAccessDecide.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: "issue:read",
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by test grant.",
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: ISSUE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
body: "hello ceo",
|
||||
});
|
||||
mockIssueService.listComments.mockResolvedValue([]);
|
||||
mockIssueService.getComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: ISSUE_ID,
|
||||
companyId: COMPANY_ID,
|
||||
body: "hello ceo",
|
||||
});
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
|
||||
mockHeartbeatService.getRun.mockResolvedValue(null);
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("defaults omitted targetAgentId to the company CEO and wakes only that real agent", async () => {
|
||||
const db = createDbStub(
|
||||
[makeAgent({ id: CEO_AGENT_ID, name: "CEO", role: "ceo" })],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = await request(await createApp(db))
|
||||
.post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`)
|
||||
.send({ body: "how is it going?" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockIssueService.addComment).toHaveBeenCalledWith(
|
||||
ISSUE_ID,
|
||||
"how is it going?",
|
||||
expect.objectContaining({ userId: "local-board" }),
|
||||
expect.objectContaining({ authorType: "user" }),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
CEO_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
issueId: ISSUE_ID,
|
||||
targetAgentId: CEO_AGENT_ID,
|
||||
selectedAgentChat: true,
|
||||
taskKey: `selected-agent-chat:${ISSUE_ID}:${CEO_AGENT_ID}`,
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
issueId: ISSUE_ID,
|
||||
targetAgentId: CEO_AGENT_ID,
|
||||
selectedAgentChat: true,
|
||||
taskKey: `selected-agent-chat:${ISSUE_ID}:${CEO_AGENT_ID}`,
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
billingCode: "engineering",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a targetAgentId outside the issue company", async () => {
|
||||
const db = createDbStub([makeAgent({ companyId: "other-company" })]);
|
||||
|
||||
const res = await request(await createApp(db))
|
||||
.post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`)
|
||||
.send({ body: "hello", targetAgentId: TARGET_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.details.code).toBe("invalid_target_agent");
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unavailable selected chat targets with a clear conflict", async () => {
|
||||
const db = createDbStub([makeAgent({ status: "paused" })]);
|
||||
|
||||
const res = await request(await createApp(db))
|
||||
.post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`)
|
||||
.send({ body: "hello", targetAgentId: TARGET_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details.code).toBe("target_agent_unavailable");
|
||||
expect(res.body.details.targetAgentStatus).toBe("paused");
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects retargeting while a different selected-agent chat run is active", async () => {
|
||||
const activeAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const db = createDbStub(
|
||||
[makeAgent()],
|
||||
[{ id: "run-active", agentId: activeAgentId, status: "running" }],
|
||||
);
|
||||
|
||||
const res = await request(await createApp(db))
|
||||
.post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`)
|
||||
.send({ body: "hello", targetAgentId: TARGET_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details.code).toBe("selected_agent_chat_target_active");
|
||||
expect(res.body.details.activeTargetAgentId).toBe(activeAgentId);
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces issue read authorization on issue-backed chat read surfaces", async () => {
|
||||
mockAccessDecide.mockResolvedValue({
|
||||
allowed: false,
|
||||
action: "issue:read",
|
||||
reason: "deny_low_trust_boundary",
|
||||
explanation: "Issue is outside this low-trust boundary.",
|
||||
});
|
||||
const app = await createApp(createDbStub());
|
||||
|
||||
const [comments, comment, interactions] = await Promise.all([
|
||||
request(app).get(`/api/issues/${ISSUE_ID}/comments`),
|
||||
request(app).get(`/api/issues/${ISSUE_ID}/comments/comment-1`),
|
||||
request(app).get(`/api/issues/${ISSUE_ID}/interactions`),
|
||||
]);
|
||||
|
||||
for (const res of [comments, comment, interactions]) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Issue is outside this actor's authorization boundary" });
|
||||
}
|
||||
expect(mockIssueService.listComments).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.getComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,45 @@ export function agentRoutes(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function decideIssueRead(req: Request, issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
status?: string | null;
|
||||
}) {
|
||||
return access.decide({
|
||||
actor: req.actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId ?? null,
|
||||
parentIssueId: issue.parentId ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
status: issue.status ?? null,
|
||||
},
|
||||
scope: {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId ?? null,
|
||||
parentIssueId: issue.parentId ?? null,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters<typeof decideIssueRead>[1]) {
|
||||
const decision = await decideIssueRead(req, issue);
|
||||
if (decision.allowed) return true;
|
||||
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function filterAgentsForActor<T extends Record<string, unknown>>(
|
||||
req: Request,
|
||||
rows: T[],
|
||||
@@ -3616,6 +3655,15 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const targetAgentId =
|
||||
typeof req.query.targetAgentId === "string" && req.query.targetAgentId.trim().length > 0
|
||||
? req.query.targetAgentId.trim()
|
||||
: null;
|
||||
if (targetAgentId && !isUuidLike(targetAgentId)) {
|
||||
res.status(400).json({ error: "targetAgentId must be a UUID" });
|
||||
return;
|
||||
}
|
||||
|
||||
const liveRuns = await db
|
||||
.select({
|
||||
@@ -3650,6 +3698,7 @@ export function agentRoutes(
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
|
||||
...(targetAgentId ? [eq(heartbeatRuns.agentId, targetAgentId)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
@@ -3670,20 +3719,31 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const targetAgentId =
|
||||
typeof req.query.targetAgentId === "string" && req.query.targetAgentId.trim().length > 0
|
||||
? req.query.targetAgentId.trim()
|
||||
: null;
|
||||
if (targetAgentId && !isUuidLike(targetAgentId)) {
|
||||
res.status(400).json({ error: "targetAgentId must be a UUID" });
|
||||
return;
|
||||
}
|
||||
|
||||
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
|
||||
if (
|
||||
run &&
|
||||
(
|
||||
(run.status !== "queued" && run.status !== "running") ||
|
||||
run.issueId !== issue.id
|
||||
run.issueId !== issue.id ||
|
||||
(targetAgentId && run.agentId !== targetAgentId)
|
||||
)
|
||||
) {
|
||||
run = null;
|
||||
}
|
||||
|
||||
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
|
||||
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId);
|
||||
const fallbackAgentId = targetAgentId ?? (issue.status === "in_progress" ? issue.assigneeAgentId : null);
|
||||
if (!run && fallbackAgentId) {
|
||||
const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(fallbackAgentId);
|
||||
const candidateIssueId = asNonEmptyString(candidateRun?.issueId);
|
||||
if (candidateRun && candidateIssueId === issue.id) {
|
||||
run = candidateRun;
|
||||
|
||||
+235
-1
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
rejectIssueThreadInteractionSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
selectedAgentChatCommentSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
updateDocumentAnnotationThreadSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
@@ -139,6 +140,11 @@ import {
|
||||
} from "../services/trust-preset-resolver.js";
|
||||
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
const SELECTED_AGENT_CHAT_UNAVAILABLE_STATUSES = new Set([
|
||||
"paused",
|
||||
"pending_approval",
|
||||
"terminated",
|
||||
]);
|
||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
interrupt: z.boolean().optional(),
|
||||
});
|
||||
@@ -743,6 +749,10 @@ function isClosedIssueStatus(status: string | null | undefined): status is "done
|
||||
return status === "done" || status === "cancelled";
|
||||
}
|
||||
|
||||
function selectedAgentChatTaskKey(issueId: string, targetAgentId: string) {
|
||||
return `selected-agent-chat:${issueId}:${targetAgentId}`;
|
||||
}
|
||||
|
||||
function shouldImplicitlyMoveCommentedIssueToTodo(input: {
|
||||
issueStatus: string | null | undefined;
|
||||
assigneeAgentId: string | null | undefined;
|
||||
@@ -2223,6 +2233,54 @@ export function issueRoutes(
|
||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
async function resolveActiveIssueRunForAgent(issue: {
|
||||
id: string;
|
||||
executionRunId?: string | null;
|
||||
}, agentId: string) {
|
||||
let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
|
||||
if (
|
||||
run &&
|
||||
run.status === "running" &&
|
||||
run.agentId === agentId &&
|
||||
run.contextSnapshot &&
|
||||
typeof run.contextSnapshot === "object" &&
|
||||
(run.contextSnapshot as Record<string, unknown>).issueId === issue.id
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
|
||||
run = await heartbeat.getActiveRunForAgent(agentId);
|
||||
const activeIssueId =
|
||||
run &&
|
||||
run.contextSnapshot &&
|
||||
typeof run.contextSnapshot === "object" &&
|
||||
typeof (run.contextSnapshot as Record<string, unknown>).issueId === "string"
|
||||
? ((run.contextSnapshot as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
return run?.status === "running" && activeIssueId === issue.id ? run : null;
|
||||
}
|
||||
|
||||
async function resolveAnyActiveSelectedAgentChatRun(issue: { id: string; companyId: string }) {
|
||||
return db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'selectedAgentChat' = 'true'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function operatorInterruptCancelOptions(input: { issueId: string; actor: ReturnType<typeof getActorInfo> }) {
|
||||
return {
|
||||
errorCode: "operator_interrupted",
|
||||
@@ -6021,6 +6079,7 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const afterCommentId =
|
||||
typeof req.query.after === "string" && req.query.after.trim().length > 0
|
||||
? req.query.after.trim()
|
||||
@@ -6055,6 +6114,7 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const actor = getActorInfo(req);
|
||||
const interactionSvc = issueThreadInteractionService(db);
|
||||
const expiredInteractions = await interactionSvc.expireRequestConfirmationsSupersededByHistoricalComments(issue);
|
||||
@@ -6392,6 +6452,7 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const comment = await svc.getComment(commentId);
|
||||
if (!comment || comment.issueId !== id) {
|
||||
res.status(404).json({ error: "Comment not found" });
|
||||
@@ -6400,6 +6461,179 @@ export function issueRoutes(
|
||||
res.json(comment);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/issues/:id/selected-agent-chat/comments",
|
||||
validate(selectedAgentChatCommentSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
assertBoard(req);
|
||||
|
||||
const requestedTargetAgentId = req.body.targetAgentId ?? null;
|
||||
const targetAgent = requestedTargetAgentId
|
||||
? await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, requestedTargetAgentId))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, issue.companyId), eq(agents.role, "ceo"), notInArray(agents.status, ["terminated"])))
|
||||
.orderBy(desc(agents.createdAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!targetAgent || targetAgent.companyId !== issue.companyId) {
|
||||
throw unprocessable(
|
||||
requestedTargetAgentId
|
||||
? "targetAgentId must reference an agent in the issue company"
|
||||
: "No CEO agent is available as the default selected-agent chat target",
|
||||
{
|
||||
code: requestedTargetAgentId ? "invalid_target_agent" : "default_target_agent_not_found",
|
||||
targetAgentId: requestedTargetAgentId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (SELECTED_AGENT_CHAT_UNAVAILABLE_STATUSES.has(targetAgent.status)) {
|
||||
throw conflict("Selected-agent chat target is unavailable", {
|
||||
code: "target_agent_unavailable",
|
||||
targetAgentId: targetAgent.id,
|
||||
targetAgentStatus: targetAgent.status,
|
||||
});
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const interruptRequested = req.body.interrupt === true;
|
||||
const activeSelectedChatRun = await resolveAnyActiveSelectedAgentChatRun(issue);
|
||||
if (activeSelectedChatRun && activeSelectedChatRun.agentId !== targetAgent.id) {
|
||||
if (!interruptRequested) {
|
||||
throw conflict("Another selected-agent chat target is already running for this issue", {
|
||||
code: "selected_agent_chat_target_active",
|
||||
activeRunId: activeSelectedChatRun.id,
|
||||
activeTargetAgentId: activeSelectedChatRun.agentId,
|
||||
requestedTargetAgentId: targetAgent.id,
|
||||
});
|
||||
}
|
||||
const cancelled = await heartbeat.cancelRun(
|
||||
activeSelectedChatRun.id,
|
||||
"Interrupted by selected-agent chat retarget",
|
||||
operatorInterruptCancelOptions({ issueId: issue.id, actor }),
|
||||
);
|
||||
if (cancelled) {
|
||||
await logActivity(db, {
|
||||
companyId: cancelled.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "heartbeat.cancelled",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: cancelled.id,
|
||||
details: {
|
||||
agentId: cancelled.agentId,
|
||||
source: "selected_agent_chat_retarget",
|
||||
issueId: issue.id,
|
||||
cancellationKind: "operator_interrupted",
|
||||
operatorInterrupted: true,
|
||||
requestedTargetAgentId: targetAgent.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sameTargetActiveRun = await resolveActiveIssueRunForAgent(issue, targetAgent.id);
|
||||
const sourceTrust = await sourceTrustForActorWrite(issue, actor);
|
||||
const comment = await svc.addComment(id, req.body.body, {
|
||||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
}, {
|
||||
authorType: req.body.authorType ?? "user",
|
||||
presentation: req.body.presentation ?? null,
|
||||
metadata: req.body.metadata ?? null,
|
||||
sourceTrust,
|
||||
});
|
||||
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
commentId: comment.id,
|
||||
bodySnippet: comment.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
source: "selected_agent_chat",
|
||||
targetAgentId: targetAgent.id,
|
||||
targetAgentName: targetAgent.name,
|
||||
},
|
||||
});
|
||||
|
||||
const taskKey = selectedAgentChatTaskKey(issue.id, targetAgent.id);
|
||||
void heartbeat
|
||||
.wakeup(targetAgent.id, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey,
|
||||
commentId: comment.id,
|
||||
targetAgentId: targetAgent.id,
|
||||
selectedAgentChat: true,
|
||||
mutation: "selected_agent_chat_comment",
|
||||
},
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey,
|
||||
commentId: comment.id,
|
||||
wakeCommentId: comment.id,
|
||||
wakeReason: "issue_commented",
|
||||
source: "selected_agent_chat.comment",
|
||||
selectedAgentChat: true,
|
||||
targetAgentId: targetAgent.id,
|
||||
projectId: issue.projectId ?? null,
|
||||
goalId: issue.goalId ?? null,
|
||||
billingCode: issue.billingCode ?? null,
|
||||
...(sameTargetActiveRun ? { activeRunId: sameTargetActiveRun.id } : {}),
|
||||
},
|
||||
})
|
||||
.catch((err) => logger.warn({
|
||||
err,
|
||||
issueId: issue.id,
|
||||
agentId: targetAgent.id,
|
||||
}, "failed to wake selected-agent chat target on issue comment"));
|
||||
|
||||
res.status(201).json({
|
||||
comment,
|
||||
targetAgent: {
|
||||
id: targetAgent.id,
|
||||
name: targetAgent.name,
|
||||
role: targetAgent.role,
|
||||
status: targetAgent.status,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/issues/:id/comments/:commentId", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
updateIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
addIssueCommentSchema,
|
||||
selectedAgentChatCommentSchema,
|
||||
checkoutIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueWorkProductSchema,
|
||||
@@ -618,6 +619,7 @@ const CREATED_OPERATIONS = new Set([
|
||||
"POST /api/issues/{id}/children",
|
||||
"POST /api/issues/{id}/interactions",
|
||||
"POST /api/issues/{id}/comments",
|
||||
"POST /api/issues/{id}/selected-agent-chat/comments",
|
||||
"POST /api/companies/{companyId}/issues/{issueId}/attachments",
|
||||
"POST /api/companies/{companyId}/projects",
|
||||
"POST /api/projects/{id}/workspaces",
|
||||
@@ -1503,6 +1505,18 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/selected-agent-chat/comments",
|
||||
tags: ["issues"],
|
||||
summary: "Add an issue-backed selected-agent chat comment",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(selectedAgentChatCommentSchema),
|
||||
},
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict, 422: r.unprocessable },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/issues/{id}/comments/{commentId}",
|
||||
@@ -2793,7 +2807,10 @@ registry.registerPath({
|
||||
path: "/api/issues/{issueId}/live-runs",
|
||||
tags: ["runs"],
|
||||
summary: "List live runs for an issue",
|
||||
request: { params: z.object({ issueId: z.string() }) },
|
||||
request: {
|
||||
params: z.object({ issueId: z.string() }),
|
||||
query: z.object({ targetAgentId: z.string().uuid().optional() }).optional(),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
@@ -2802,7 +2819,10 @@ registry.registerPath({
|
||||
path: "/api/issues/{issueId}/active-run",
|
||||
tags: ["runs"],
|
||||
summary: "Get active run for an issue",
|
||||
request: { params: z.object({ issueId: z.string() }) },
|
||||
request: {
|
||||
params: z.object({ issueId: z.string() }),
|
||||
query: z.object({ targetAgentId: z.string().uuid().optional() }).optional(),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user