diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9b170e8114b..624780ed909 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -264,6 +264,7 @@ export { issueCommentMetadataSectionSchema, issueCommentMetadataSchema, addIssueCommentSchema, + selectedAgentChatCommentSchema, issueThreadInteractionStatusSchema, issueThreadInteractionKindSchema, issueThreadInteractionContinuationPolicySchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 7d7454c494d..145676b77f5 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -548,6 +548,12 @@ export const addIssueCommentSchema = z.object({ export type AddIssueComment = z.infer; +export const selectedAgentChatCommentSchema = addIssueCommentSchema.extend({ + targetAgentId: z.string().uuid().optional().nullable(), +}); + +export type SelectedAgentChatComment = z.infer; + export const issueThreadInteractionStatusSchema = z.enum(ISSUE_THREAD_INTERACTION_STATUSES); export const issueThreadInteractionKindSchema = z.enum(ISSUE_THREAD_INTERACTION_KINDS); export const issueThreadInteractionContinuationPolicySchema = z.enum( diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index d0549059a30..02246d9ad71 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -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(), diff --git a/server/src/__tests__/issue-selected-agent-chat-routes.test.ts b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts new file mode 100644 index 00000000000..2b8d0b7a2e9 --- /dev/null +++ b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts @@ -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 = {}) { + 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 = {}) { + 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>) { + return { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + then: (resolve: (value: Array>) => unknown, reject?: (reason: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject), + }; +} + +function createDbStub(...selectRows: Array>>) { + const rowsQueue = [...selectRows]; + return { + select: vi.fn(() => thenableRows(rowsQueue.shift() ?? [])), + }; +} + +async function createApp(db: Record) { + const [{ issueRoutes }, { errorHandler }] = await Promise.all([ + vi.importActual("../routes/issues.js"), + vi.importActual("../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(); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0a9688ac5e6..1ca08313405 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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[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>( 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; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 805818e8cb4..6ee09f0d079 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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).issueId === issue.id + ) { + return run; + } + + run = await heartbeat.getActiveRunForAgent(agentId); + const activeIssueId = + run && + run.contextSnapshot && + typeof run.contextSnapshot === "object" && + typeof (run.contextSnapshot as Record).issueId === "string" + ? ((run.contextSnapshot as Record).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 }) { 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; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 38dc905f8a9..38a4a319867 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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 }, });