diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 393e4d31..5515de99 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -160,6 +160,7 @@ export const ISSUE_THREAD_INTERACTION_STATUSES = [ "accepted", "rejected", "answered", + "cancelled", "expired", "failed", ] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 72d8ca73..4935257e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -396,6 +396,7 @@ export type { BudgetIncidentResolutionInput, CostEvent, CostSummary, + IssueCostSummary, CostByAgent, CostByProviderModel, CostByBiller, @@ -670,6 +671,7 @@ export { createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, + cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, @@ -704,6 +706,7 @@ export { type CreateIssueThreadInteraction, type AcceptIssueThreadInteraction, type RejectIssueThreadInteraction, + type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index 8a77a8f2..57e6be62 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -28,6 +28,16 @@ export interface CostSummary { utilizationPercent: number; } +export interface IssueCostSummary { + issueId: string; + issueCount: number; + includeDescendants: boolean; + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + export interface CostByAgent { agentId: string; agentName: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a574f854..669575cc 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -227,7 +227,7 @@ export type { RoutineExecutionIssueOrigin, RoutineListItem, } from "./routine.js"; -export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; +export type { CostEvent, CostSummary, IssueCostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js"; export type { AgentWakeupResponse, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 8ee1821d..853159be 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -386,6 +386,8 @@ export interface AskUserQuestionsAnswer { export interface AskUserQuestionsResult { version: 1; answers: AskUserQuestionsAnswer[]; + cancelled?: true; + cancellationReason?: string | null; summaryMarkdown?: string | null; } diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e9141631..dfa28d72 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -177,6 +177,7 @@ export { createIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, + cancelIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, @@ -194,6 +195,7 @@ export { type CreateIssueThreadInteraction, type AcceptIssueThreadInteraction, type RejectIssueThreadInteraction, + type CancelIssueThreadInteraction, type RespondIssueThreadInteraction, type LinkIssueApproval, type CreateIssueAttachmentMetadata, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 4658cd8d..882b3dd8 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -330,6 +330,8 @@ export const askUserQuestionsAnswerSchema = z.object({ export const askUserQuestionsResultSchema = z.object({ version: z.literal(1), answers: z.array(askUserQuestionsAnswerSchema).max(20), + cancelled: z.literal(true).optional(), + cancellationReason: z.string().trim().max(4000).nullable().optional(), summaryMarkdown: z.string().max(20000).nullable().optional(), }); @@ -446,6 +448,11 @@ export const rejectIssueThreadInteractionSchema = z.object({ }); export type RejectIssueThreadInteraction = z.infer; +export const cancelIssueThreadInteractionSchema = z.object({ + reason: z.string().trim().max(4000).optional(), +}); +export type CancelIssueThreadInteraction = z.infer; + export const respondIssueThreadInteractionSchema = z.object({ answers: z.array(askUserQuestionsAnswerSchema).max(20), summaryMarkdown: multilineTextSchema.pipe(z.string().max(20000)).nullable().optional(), diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index c9428fff..f9e32f0f 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -3,7 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll } from "vitest"; import { randomUUID } from "node:crypto"; -import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db"; import { costService } from "../services/costs.ts"; import { financeService } from "../services/finance.ts"; import { @@ -45,6 +45,10 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), })); +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), +})); const mockHeartbeatService = vi.hoisted(() => ({ cancelBudgetScopeWork: vi.fn().mockResolvedValue(undefined), })); @@ -57,6 +61,15 @@ const mockCostService = vi.hoisted(() => ({ byAgentModel: vi.fn().mockResolvedValue([]), byProvider: vi.fn().mockResolvedValue([]), byBiller: vi.fn().mockResolvedValue([]), + issueTreeSummary: vi.fn().mockResolvedValue({ + issueId: "issue-1", + issueCount: 1, + includeDescendants: true, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }), windowSpend: vi.fn().mockResolvedValue([]), byProject: vi.fn().mockResolvedValue([]), })); @@ -87,6 +100,7 @@ function registerModuleMocks() { financeService: () => mockFinanceService, companyService: () => mockCompanyService, agentService: () => mockAgentService, + issueService: () => mockIssueService, heartbeatService: () => mockHeartbeatService, logActivity: mockLogActivity, })); @@ -161,6 +175,16 @@ beforeEach(() => { budgetMonthlyCents: 100, spentMonthlyCents: 0, }); + mockIssueService.getById.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.getByIdentifier.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + }); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); }); @@ -201,6 +225,24 @@ describe("cost routes", () => { }); }); + it("returns issue subtree cost summaries for issue refs", async () => { + const app = await createApp(); + const res = await request(app).get("/api/issues/PAP-1/cost-summary"); + + expect(res.status).toBe(200); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1"); + expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1"); + expect(res.body).toEqual({ + issueId: "issue-1", + issueCount: 1, + includeDescendants: true, + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }); + }); + it("returns 400 for invalid finance event list limits", async () => { const { parseCostLimit } = await loadCostParsers(); expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i); @@ -351,6 +393,7 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { afterEach(async () => { await db.delete(financeEvents); await db.delete(costEvents); + await db.delete(issues); await db.delete(projects); await db.delete(agents); await db.delete(companies); @@ -435,6 +478,143 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { expect(byAgentModelRow?.costCents).toBe(4_000_000_000); }); + it("aggregates issue costs across recursive descendants only", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const grandchildIssueId = randomUUID(); + const siblingIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "in_progress", + priority: "medium", + issueNumber: 1, + identifier: "TST-1", + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Child", + status: "done", + priority: "medium", + issueNumber: 2, + identifier: "TST-2", + }, + { + id: grandchildIssueId, + companyId, + parentId: childIssueId, + title: "Grandchild", + status: "done", + priority: "medium", + issueNumber: 3, + identifier: "TST-3", + }, + { + id: siblingIssueId, + companyId, + title: "Sibling", + status: "done", + priority: "medium", + issueNumber: 4, + identifier: "TST-4", + }, + ]); + await db.insert(costEvents).values([ + { + companyId, + agentId, + issueId: rootIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 10, + cachedInputTokens: 1, + outputTokens: 2, + costCents: 100, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + issueId: childIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 20, + cachedInputTokens: 2, + outputTokens: 4, + costCents: 200, + occurredAt: new Date("2026-04-10T00:01:00.000Z"), + }, + { + companyId, + agentId, + issueId: grandchildIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 30, + cachedInputTokens: 3, + outputTokens: 6, + costCents: 300, + occurredAt: new Date("2026-04-10T00:02:00.000Z"), + }, + { + companyId, + agentId, + issueId: siblingIssueId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 40, + cachedInputTokens: 4, + outputTokens: 8, + costCents: 400, + occurredAt: new Date("2026-04-10T00:03:00.000Z"), + }, + ]); + + const summary = await costs.issueTreeSummary(companyId, rootIssueId); + + expect(summary).toEqual({ + issueId: rootIssueId, + issueCount: 3, + includeDescendants: true, + costCents: 600, + inputTokens: 60, + cachedInputTokens: 6, + outputTokens: 12, + }); + }); + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { const companyId = randomUUID(); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index d873e080..0c82acfb 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -17,6 +17,7 @@ const mockInteractionService = vi.hoisted(() => ({ rejectInteraction: vi.fn(), rejectSuggestedTasks: vi.fn(), answerQuestions: vi.fn(), + cancelQuestions: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ @@ -249,6 +250,36 @@ describe.sequential("issue thread interaction routes", () => { updatedAt: "2026-04-20T12:06:00.000Z", resolvedAt: "2026-04-20T12:06:00.000Z", }); + mockInteractionService.cancelQuestions.mockResolvedValue({ + id: "interaction-2", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "ask_user_questions", + status: "cancelled", + continuationPolicy: "wake_assignee", + idempotencyKey: null, + sourceCommentId: "comment-2", + sourceRunId: "run-2", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Scope?", + selectionMode: "single", + options: [{ id: "phase-1", label: "Phase 1" }], + }], + }, + result: { + version: 1, + answers: [], + cancelled: true, + cancellationReason: null, + summaryMarkdown: null, + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }); }); it("lists and creates board-authored interactions", async () => { @@ -363,6 +394,42 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("cancels question interactions and emits a continuation wake", async () => { + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-2/cancel") + .send({}); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("cancelled"); + expect(mockInteractionService.cancelQuestions).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + "interaction-2", + {}, + expect.objectContaining({ userId: "local-board" }), + ); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + interactionId: "interaction-2", + interactionKind: "ask_user_questions", + interactionStatus: "cancelled", + sourceCommentId: "comment-2", + sourceRunId: "run-2", + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_cancelled", + }), + ); + }); + it("accepts request confirmations and wakes the current assignee when configured for accept-only wakeups", async () => { mockInteractionService.acceptInteraction.mockResolvedValueOnce({ interaction: { diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index ac418d34..4ee44941 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -427,6 +427,85 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { })).rejects.toThrow("Interaction has already been resolved"); }); + it("persists cancelled ask_user_questions interactions without answer data", async () => { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Cancel question answers", + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Question parent", + status: "in_review", + priority: "medium", + }); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "ask_user_questions", + continuationPolicy: "wake_assignee", + payload: { + version: 1, + questions: [{ + id: "scope", + prompt: "Choose the scope", + selectionMode: "single", + required: true, + options: [ + { id: "phase-1", label: "Phase 1" }, + { id: "phase-2", label: "Phase 2" }, + ], + }], + }, + }, { + userId: "local-board", + }); + + const cancelled = await interactionsSvc.cancelQuestions({ + id: issueId, + companyId, + }, created.id, { + reason: "Not needed anymore", + }, { + userId: "local-board", + }); + + expect(cancelled.status).toBe("cancelled"); + expect(cancelled.result).toEqual({ + version: 1, + answers: [], + cancelled: true, + cancellationReason: "Not needed anymore", + summaryMarkdown: null, + }); + + await expect(interactionsSvc.answerQuestions({ + id: issueId, + companyId, + }, created.id, { + answers: [{ questionId: "scope", optionIds: ["phase-1"] }], + }, { + userId: "local-board", + })).rejects.toThrow("Interaction has already been resolved"); + }); + it("reuses the existing interaction when the same idempotency key is submitted twice", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 6bef1858..3f3514e6 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -14,6 +14,7 @@ import { financeService, companyService, agentService, + issueService, heartbeatService, logActivity, } from "../services/index.js"; @@ -58,6 +59,14 @@ export function costRoutes( const budgets = budgetService(db, budgetHooks); const companies = companyService(db); const agents = agentService(db); + const issues = issueService(db); + + async function resolveIssueByRef(rawId: string) { + if (/^[A-Z]+-\d+$/i.test(rawId)) { + return issues.getByIdentifier(rawId); + } + return issues.getById(rawId); + } router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => { const companyId = req.params.companyId as string; @@ -126,6 +135,18 @@ export function costRoutes( res.json(summary); }); + router.get("/issues/:id/cost-summary", async (req, res) => { + const rawId = req.params.id as string; + const issue = await resolveIssueByRef(rawId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const summary = await costs.issueTreeSummary(issue.companyId, issue.id); + res.json(summary); + }); + router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ab6a4232..60916144 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7,6 +7,7 @@ import { issueExecutionDecisions } from "@paperclipai/db"; import { addIssueCommentSchema, acceptIssueThreadInteractionSchema, + cancelIssueThreadInteractionSchema, createIssueAttachmentMetadataSchema, createIssueThreadInteractionSchema, createIssueWorkProductSchema, @@ -3182,6 +3183,58 @@ export function issueRoutes( }, ); + router.post( + "/issues/:id/interactions/:interactionId/cancel", + validate(cancelIssueThreadInteractionSchema), + async (req, res) => { + const id = req.params.id as string; + const interactionId = req.params.interactionId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + assertBoard(req); + + const actor = getActorInfo(req); + const interaction = await issueThreadInteractionService(db).cancelQuestions(issue, interactionId, req.body, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.thread_interaction_cancelled", + entityType: "issue", + entityId: issue.id, + details: { + interactionId: interaction.id, + interactionKind: interaction.kind, + interactionStatus: interaction.status, + cancellationReason: + interaction.kind === "ask_user_questions" + ? (interaction.result?.cancellationReason ?? null) + : null, + }, + }); + + queueResolvedInteractionContinuationWakeup({ + heartbeat, + issue, + interaction, + actor, + source: "issue.interaction.cancel", + }); + + res.json(interaction); + }, + ); + router.get("/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/services/costs.ts b/server/src/services/costs.ts index 88825b49..abea444e 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,4 +1,5 @@ -import { and, desc, eq, gte, isNotNull, lt, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { Db } from "@paperclipai/db"; import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; @@ -134,6 +135,64 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { }; }, + issueTreeSummary: async (companyId: string, issueId: string) => { + // Callers must resolve and authorize a visible root issue before invoking this. + // The route does that so zero counts are not mistaken for a missing root. + const childIssues = alias(issues, "child"); + const issueTreeCondition = sql` + ${issues.id} IN ( + WITH RECURSIVE issue_tree(id) AS ( + SELECT ${issues.id} + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.id} = ${issueId} + AND ${issues.hiddenAt} IS NULL + UNION ALL + SELECT ${childIssues.id} + FROM ${issues} ${childIssues} + JOIN issue_tree ON ${childIssues.parentId} = issue_tree.id + WHERE ${childIssues.companyId} = ${companyId} + AND ${childIssues.hiddenAt} IS NULL + ) + SELECT id FROM issue_tree + ) + `; + + const [row] = await db + .select({ + issueCount: sql`count(distinct ${issues.id})::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), + }) + .from(issues) + .leftJoin( + costEvents, + and( + eq(costEvents.companyId, companyId), + eq(costEvents.issueId, issues.id), + ), + ) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + issueTreeCondition, + ), + ); + + return { + issueId, + issueCount: Number(row?.issueCount ?? 0), + includeDescendants: true, + costCents: Number(row?.costCents ?? 0), + inputTokens: Number(row?.inputTokens ?? 0), + cachedInputTokens: Number(row?.cachedInputTokens ?? 0), + outputTokens: Number(row?.outputTokens ?? 0), + }; + }, + byAgent: async (companyId: string, range?: CostDateRange) => { const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)]; if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index a139c920..de6e1654 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -13,6 +13,7 @@ import type { AcceptIssueThreadInteraction, AskUserQuestionsAnswer, AskUserQuestionsInteraction, + CancelIssueThreadInteraction, CreateIssueThreadInteraction, IssueThreadInteraction, RequestConfirmationInteraction, @@ -26,6 +27,7 @@ import { acceptIssueThreadInteractionSchema, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, + cancelIssueThreadInteractionSchema, createIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, requestConfirmationPayloadSchema, @@ -1148,5 +1150,60 @@ export function issueThreadInteractionService(db: Db) { await touchIssue(db, issue.id); return hydrateInteraction(updated); }, + + cancelQuestions: async ( + issue: { id: string; companyId: string }, + interactionId: string, + input: CancelIssueThreadInteraction, + actor: InteractionActor, + ) => { + const data = cancelIssueThreadInteractionSchema.parse(input); + const current = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0] ?? null); + + if (!current) throw notFound("Interaction not found"); + if (current.companyId !== issue.companyId || current.issueId !== issue.id) { + throw notFound("Interaction not found"); + } + if (current.kind !== "ask_user_questions") { + throw unprocessable("Only ask_user_questions interactions can be cancelled"); + } + if (current.status !== "pending") { + throw conflict("Interaction has already been resolved"); + } + + const reason = data.reason?.trim() || null; + const [updated] = await db + .update(issueThreadInteractions) + .set({ + status: "cancelled", + result: { + version: 1, + answers: [], + cancelled: true, + cancellationReason: reason, + summaryMarkdown: null, + }, + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(issueThreadInteractions.id, interactionId), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + + if (!updated) { + throw conflict("Interaction has already been resolved"); + } + + await touchIssue(db, issue.id); + return hydrateInteraction(updated); + }, }; } diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 61abcba6..027222a0 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -8,6 +8,7 @@ import type { FeedbackVote, Issue, IssueAttachment, + IssueCostSummary, IssueComment, IssueDocument, IssueLabel, @@ -159,6 +160,8 @@ export const issuesApi = { api.post(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}), rejectInteraction: (id: string, interactionId: string, reason?: string) => api.post(`/issues/${id}/interactions/${interactionId}/reject`, reason ? { reason } : {}), + cancelInteraction: (id: string, interactionId: string, reason?: string) => + api.post(`/issues/${id}/interactions/${interactionId}/cancel`, reason ? { reason } : {}), respondToInteraction: ( id: string, interactionId: string, @@ -168,6 +171,7 @@ export const issuesApi = { getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), + getCostSummary: (id: string) => api.get(`/issues/${id}/cost-summary`), listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters ?? {})) { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ed615d56..facf13e7 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -3464,6 +3464,11 @@ export function IssueChatThread({ return; } + if (typeof document === "undefined") { + finish(); + return; + } + const el = document.getElementById(latestCommentAnchor); if (!el) { // Row hasn't been rendered into the virtualizer's buffer yet — nudge diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 7ab4bf1c..90d5bbb3 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import type { ComponentProps, ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; -import type { Issue, RunLivenessState } from "@paperclipai/shared"; +import type { ActivityEvent, Issue, RunLivenessState } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue } from "../api/heartbeats"; @@ -62,6 +62,23 @@ function createRun(overrides: Partial = {}): RunForIssue { }; } +function createActivity(overrides: Partial = {}): ActivityEvent { + return { + id: "activity-1", + companyId: "company-1", + actorType: "system", + actorId: "system", + action: "issue.updated", + entityType: "issue", + entityId: "issue-1", + agentId: null, + runId: null, + details: null, + createdAt: new Date("2026-04-18T19:57:00.000Z"), + ...overrides, + }; +} + function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", @@ -139,6 +156,8 @@ function renderLedger(props: Partial { expect(container.textContent).toContain("Last useful action Unavailable"); }); + it("interleaves run rows and activity rows by timestamp", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-oldest", + startedAt: "2026-04-18T19:55:00.000Z", + createdAt: "2026-04-18T19:55:00.000Z", + }), + createRun({ + runId: "run-newest", + startedAt: "2026-04-18T19:59:00.000Z", + createdAt: "2026-04-18T19:59:00.000Z", + }), + ], + activityEvents: [ + createActivity({ + id: "activity-middle", + action: "activity-middle", + createdAt: new Date("2026-04-18T19:57:00.000Z"), + }), + ], + renderActivityEvent: (event) => ( +
{event.action}
+ ), + }); + + const text = container.textContent ?? ""; + const newestIndex = text.indexOf("run-newe"); + const activityIndex = text.indexOf("activity-middle"); + const oldestIndex = text.indexOf("run-olde"); + + expect(newestIndex).toBeGreaterThanOrEqual(0); + expect(activityIndex).toBeGreaterThan(newestIndex); + expect(oldestIndex).toBeGreaterThan(activityIndex); + }); + it("shows live runs as pending final checks without missing-data language", () => { renderLedger({ runs: [ @@ -279,12 +334,18 @@ describe("IssueRunLedger", () => { resultJson: { stopReason: "budget_paused" }, createdAt: "2026-04-18T19:56:00.000Z", }), + createRun({ + runId: "run-paused", + resultJson: { stopReason: "paused" }, + createdAt: "2026-04-18T19:55:00.000Z", + }), ], }); expect(container.textContent).toContain("timeout (30s timeout)"); expect(container.textContent).toContain("cancelled"); expect(container.textContent).toContain("budget paused"); + expect(container.textContent).toContain("paused by board"); }); it("surfaces active and completed child issue summaries", () => { @@ -328,7 +389,7 @@ describe("IssueRunLedger", () => { it("shows when older runs are clipped from the ledger", () => { renderLedger({ - runs: Array.from({ length: 10 }, (_, index) => + runs: Array.from({ length: 22 }, (_, index) => createRun({ runId: `run-${index.toString().padStart(8, "0")}`, createdAt: `2026-04-18T19:${String(index).padStart(2, "0")}:00.000Z`, @@ -336,7 +397,7 @@ describe("IssueRunLedger", () => { ), }); - expect(container.textContent).toContain("2 older runs not shown"); + expect(container.textContent).toContain("2 older items not shown"); }); it("renders stale-run banner, watchdog actions, and silence badge for live runs", () => { diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 45f9bc26..7b0a5b5e 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import type { Issue, Agent } from "@paperclipai/shared"; +import { useMemo, useState, type ReactNode } from "react"; +import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { accessApi, type CurrentBoardAccess } from "../api/access"; @@ -24,6 +24,8 @@ type IssueRunLedgerProps = { childIssues: Issue[]; agentMap: ReadonlyMap; hasLiveRuns: boolean; + activityEvents?: ActivityEvent[]; + renderActivityEvent?: (event: ActivityEvent) => ReactNode; }; type IssueRunLedgerContentProps = { @@ -33,6 +35,8 @@ type IssueRunLedgerContentProps = { issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap>; + activityEvents?: ActivityEvent[]; + renderActivityEvent?: (event: ActivityEvent) => ReactNode; pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null; canRecordWatchdogDecisions?: boolean; watchdogDecisionError?: string | null; @@ -45,6 +49,20 @@ type LedgerRun = RunForIssue & { outputSilence?: ActiveRunForIssue["outputSilence"]; }; +type LedgerFeedItem = + | { + kind: "run"; + id: string; + timestamp: string; + run: LedgerRun; + } + | { + kind: "activity"; + id: string; + timestamp: string; + event: ActivityEvent; + }; + type LivenessCopy = { label: string; tone: string; @@ -256,7 +274,7 @@ function stopReasonLabel(run: RunForIssue) { } if (stopReason === "budget_paused") return "budget paused"; if (stopReason === "cancelled") return "cancelled"; - if (stopReason === "paused") return "paused"; + if (stopReason === "paused") return "paused by board"; if (stopReason === "process_lost") return "process lost"; if (stopReason === "adapter_failed") return "adapter failed"; if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed"; @@ -345,6 +363,8 @@ export function IssueRunLedger({ childIssues, agentMap, hasLiveRuns, + activityEvents, + renderActivityEvent, }: IssueRunLedgerProps) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -405,6 +425,8 @@ export function IssueRunLedger({ issueStatus={issueStatus} childIssues={childIssues} agentMap={agentMap} + activityEvents={activityEvents} + renderActivityEvent={renderActivityEvent} pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null} canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)} watchdogDecisionError={watchdogDecisionError} @@ -420,6 +442,8 @@ export function IssueRunLedgerContent({ issueStatus, childIssues, agentMap, + activityEvents, + renderActivityEvent, pendingWatchdogDecision, canRecordWatchdogDecisions = true, watchdogDecisionError, @@ -436,6 +460,37 @@ export function IssueRunLedgerContent({ [ledgerRuns], ); const children = childIssueSummary(childIssues); + const canRenderActivityEvents = Boolean(renderActivityEvent); + const feedItems = useMemo(() => { + const items: LedgerFeedItem[] = []; + for (const run of ledgerRuns) { + items.push({ + kind: "run", + id: run.runId, + timestamp: run.startedAt ?? run.createdAt, + run, + }); + } + if (canRenderActivityEvents) { + for (const event of activityEvents ?? []) { + items.push({ + kind: "activity", + id: event.id, + timestamp: event.createdAt instanceof Date + ? event.createdAt.toISOString() + : String(event.createdAt), + event, + }); + } + } + return items.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + if (aTime !== bTime) return bTime - aTime; + if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1; + return b.id.localeCompare(a.id); + }); + }, [activityEvents, canRenderActivityEvents, ledgerRuns]); return (
@@ -578,28 +633,40 @@ export function IssueRunLedgerContent({ ) : null} - {ledgerRuns.length === 0 ? ( + {feedItems.length === 0 ? (
- Historical runs without liveness metadata will appear here once linked to this issue. + {renderActivityEvent + ? "Runs and activity will appear here once this issue has history." + : "Historical runs without liveness metadata will appear here once linked to this issue."}
) : ( -
- {ledgerRuns.slice(0, 8).map((run) => { +
+ {feedItems.slice(0, 20).map((item) => { + if (item.kind === "activity") { + return
{renderActivityEvent?.(item.event)}
; + } + const run = item.run; const liveness = livenessCopyForRun(run); const stopReason = stopReasonLabel(run); const duration = formatDuration(run.startedAt, run.finishedAt); const exhausted = hasExhaustedContinuation(run); const continuation = continuationLabel(run); const retryState = describeRunRetryState(run); + const agentName = compactAgentName(run, agentMap); return ( -
-
+
+
+ Run {run.runId.slice(0, 8)} + by {agentName} {statusLabel(run.status)} @@ -646,6 +713,7 @@ export function IssueRunLedgerContent({ {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} ) : null} + {relativeTime(item.timestamp)}
@@ -696,9 +764,9 @@ export function IssueRunLedgerContent({
); })} - {ledgerRuns.length > 8 ? ( + {feedItems.length > 20 ? (
- {ledgerRuns.length - 8} older runs not shown + {feedItems.length - 20} older items not shown
) : null}
diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx index fe9d14d7..b289c72a 100644 --- a/ui/src/components/IssueThreadInteractionCard.test.tsx +++ b/ui/src/components/IssueThreadInteractionCard.test.tsx @@ -91,6 +91,25 @@ describe("IssueThreadInteractionCard", () => { expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3); }); + it("only shows question cancellation when a cancel handler is wired", () => { + const withoutHandler = renderCard({ + interaction: pendingAskUserQuestionsInteraction, + onSubmitInteractionAnswers: vi.fn(), + }); + expect(withoutHandler.textContent).not.toContain("Cancel question"); + + act(() => root?.unmount()); + withoutHandler.remove(); + root = null; + + const withHandler = renderCard({ + interaction: pendingAskUserQuestionsInteraction, + onCancelInteraction: vi.fn(), + onSubmitInteractionAnswers: vi.fn(), + }); + expect(withHandler.textContent).toContain("Cancel question"); + }); + it("makes child tasks explicit in suggested task trees", () => { const host = renderCard({ interaction: pendingSuggestedTasksInteraction, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index c65f8b4f..d48bd5b1 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -43,6 +43,9 @@ interface IssueThreadInteractionCardProps { interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; } function resolveActorLabel(args: { @@ -72,6 +75,8 @@ function statusLabel(status: IssueThreadInteraction["status"]) { return "Rejected"; case "answered": return "Answered"; + case "cancelled": + return "Cancelled"; case "expired": return "Expired"; case "failed": @@ -100,6 +105,7 @@ function statusIcon(status: IssueThreadInteraction["status"]) { case "answered": return CheckCircle2; case "rejected": + case "cancelled": case "failed": return XCircle; case "expired": @@ -118,6 +124,7 @@ function statusClasses(status: IssueThreadInteraction["status"]) { badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100", }; case "rejected": + case "cancelled": return { shell: "border-rose-400/70 bg-transparent", badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100", @@ -636,12 +643,16 @@ function QuestionOptionButton({ function AskUserQuestionsCard({ interaction, onSubmitInteractionAnswers, + onCancelInteraction, }: { interaction: AskUserQuestionsInteraction; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; + onCancelInteraction?: ( + interaction: AskUserQuestionsInteraction, + ) => Promise | void; }) { const [draftAnswers, setDraftAnswers] = useState>(() => Object.fromEntries( @@ -652,6 +663,7 @@ function AskUserQuestionsCard({ ), ); const [working, setWorking] = useState(false); + const [cancelling, setCancelling] = useState(false); useEffect(() => { setDraftAnswers( @@ -699,6 +711,16 @@ function AskUserQuestionsCard({ } } + async function handleCancel() { + if (!onCancelInteraction) return; + setCancelling(true); + try { + await onCancelInteraction(interaction); + } finally { + setCancelling(false); + } + } + return (
@@ -765,26 +787,54 @@ function AskUserQuestionsCard({
))} -
+
Submit once after you finish the full form.
- +
+ {onCancelInteraction ? ( + + ) : null} + +
+ ) : interaction.status === "cancelled" ? ( +
+
Question cancelled
+ {interaction.result?.cancellationReason ? ( +

{interaction.result.cancellationReason}

+ ) : ( +

No answer was recorded.

+ )} +
) : (
{questions.map((question) => { @@ -1162,6 +1212,7 @@ export function IssueThreadInteractionCard({ onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, + onCancelInteraction, }: IssueThreadInteractionCardProps) { const StatusIcon = statusIcon(interaction.status); const styles = statusClasses(interaction.status); @@ -1247,6 +1298,7 @@ export function IssueThreadInteractionCard({ ) : ( { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + valueSetter?.call(textarea, value); + textarea.dispatchEvent( + new InputEvent("input", { + bubbles: true, + data: value, + inputType: "insertText", + }), + ); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); +} + async function waitForAssertion(assertion: () => void, attempts = 20) { let lastError: unknown; @@ -260,6 +279,7 @@ describe("NewIssueDialog", () => { let container: HTMLDivElement; beforeEach(() => { + vi.useRealTimers(); container = document.createElement("div"); document.body.appendChild(container); dialogState.newIssueOpen = true; @@ -411,25 +431,8 @@ describe("NewIssueDialog", () => { expect(titleInput).not.toBeNull(); expect(descriptionInput).not.toBeNull(); - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - "value", - )?.set; - valueSetter?.call(titleInput, "Typed issue"); - titleInput!.dispatchEvent(new Event("input", { bubbles: true })); - }); - await flush(); - - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - "value", - )?.set; - valueSetter?.call(descriptionInput, "Typed description"); - descriptionInput!.dispatchEvent(new Event("input", { bubbles: true })); - }); - await flush(); + await typeTextareaValue(titleInput!, "Typed issue"); + await typeTextareaValue(descriptionInput!, "Typed description"); await act(async () => { resolveProjects([ @@ -448,7 +451,7 @@ describe("NewIssueDialog", () => { const submitButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("Create Issue")); expect(submitButton).not.toBeUndefined(); - await waitForAssertion(() => { + await vi.waitFor(() => { expect(submitButton?.hasAttribute("disabled")).toBe(false); }); diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts index 42e1a90c..25333eff 100644 --- a/ui/src/lib/issue-thread-interactions.ts +++ b/ui/src/lib/issue-thread-interactions.ts @@ -87,6 +87,9 @@ export function buildIssueThreadInteractionSummary( if (interaction.status === "answered") { return count === 1 ? "Answered 1 question" : `Answered ${count} questions`; } + if (interaction.status === "cancelled") { + return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`; + } return count === 1 ? "Asked 1 question" : `Asked ${count} questions`; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c61b216a..25d73a1d 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -49,6 +49,7 @@ export const queryKeys = { comments: (issueId: string) => ["issues", "comments", issueId] as const, interactions: (issueId: string) => ["issues", "interactions", issueId] as const, feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const, + costSummary: (issueId: string) => ["issues", "cost-summary", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,