Implement selected-agent issue chat backend

This commit is contained in:
Dotta committed 2026-06-16 10:02:04 +00:00
1 parent 2d51920655
commit e26ae06035
7 files changed
+711 -12

No files matched your search

+1
View File
@@ -264,6 +264,7 @@ export {
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
selectedAgentChatCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
issueThreadInteractionContinuationPolicySchema,
+6
View File
@@ -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();
});
});
+63 -3
View File
@@ -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
View File
@@ -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;
+22 -2
View File
@@ -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 },
});