diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 30dc0b1be7..f44b968cf4 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -1574,6 +1574,39 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(rows[0]?.status).toBe("pending"); }); + it("lists interactions whose stored result predates the current schema without throwing (LOOA-629)", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Legacy result outcome"); + + // Simulate a row persisted by an older build: a resolved confirmation whose + // result.outcome is a value no longer in the current enum. A hard parse + // would 500 the whole listForIssue call and brick every consumer (web + // thread + Slack gateway notifier/digest/aging). + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId, + kind: "request_confirmation", + status: "cancelled", + continuationPolicy: { kind: "none" }, + payload: { + version: 1, + prompt: "Proceed with the current draft?", + }, + result: { + version: 1, + outcome: "withdrawn_by_creator", + }, + createdByUserId: "local-board", + }); + + const listed = await interactionsSvc.listForIssue(issueId); + expect(listed).toHaveLength(1); + expect(listed[0]?.kind).toBe("request_confirmation"); + // The unparseable result degrades to null; the interaction still lists. + expect(listed[0]?.result).toBeNull(); + expect(listed[0]?.status).toBe("cancelled"); + }); + it("does not supersede request confirmations for agent, system, or older user comments", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions"); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 153d548d3f..3d3ef7fd7b 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -47,6 +47,7 @@ import { suggestTasksResultSchema, submitIssueThreadInteractionVerdictsSchema, } from "@paperclipai/shared"; +import { z } from "zod"; import { conflict, notFound, unprocessable } from "../errors.js"; import { getTelemetryClient } from "../telemetry.js"; import { issueService, runWorkspaceIsFinalized } from "./issues.js"; @@ -148,6 +149,32 @@ function isEquivalentCreateRequest( ); } +/** + * Parse a stored interaction `result` blob tolerantly. Rows persisted by older + * builds can carry a `result` shape that predates the current schema — e.g. a + * legacy `outcome` value ("withdrawn_by_creator") no longer in the enum. + * `hydrateInteraction` runs over every row in `listForIssue`, so a hard + * `.parse()` on one stale row throws and 500s the *entire* issue's interaction + * list — which bricks both the web thread and plugin consumers such as the + * Slack gateway's notifier/digest/aging loops (LOOA-629). Degrade an + * unparseable `result` to `null` (the interaction still lists; a + * resolved-but-unparseable result is treated as absent) instead of throwing. + */ +function parseStoredInteractionResult( + schema: S, + raw: unknown, + row: Pick, +): z.infer | null { + if (raw == null) return null; + const parsed = schema.safeParse(raw); + if (parsed.success) return parsed.data; + console.warn( + `[paperclip] Dropping unparseable ${row.kind} interaction result for interaction ${row.id}`, + parsed.error.issues, + ); + return null; +} + function hydrateInteraction( row: IssueThreadInteractionRow, ): IssueThreadInteraction { @@ -164,35 +191,35 @@ function hydrateInteraction( ...base, kind: "suggest_tasks", payload: suggestTasksPayloadSchema.parse(row.payload), - result: row.result ? suggestTasksResultSchema.parse(row.result) : null, + result: parseStoredInteractionResult(suggestTasksResultSchema, row.result, row), } satisfies SuggestTasksInteraction; case "ask_user_questions": return { ...base, kind: "ask_user_questions", payload: askUserQuestionsPayloadSchema.parse(row.payload), - result: row.result ? askUserQuestionsResultSchema.parse(row.result) : null, + result: parseStoredInteractionResult(askUserQuestionsResultSchema, row.result, row), } satisfies AskUserQuestionsInteraction; case "request_confirmation": return { ...base, kind: "request_confirmation", payload: requestConfirmationPayloadSchema.parse(row.payload), - result: row.result ? requestConfirmationResultSchema.parse(row.result) : null, + result: parseStoredInteractionResult(requestConfirmationResultSchema, row.result, row), } satisfies RequestConfirmationInteraction; case "request_checkbox_confirmation": return { ...base, kind: "request_checkbox_confirmation", payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload), - result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null, + result: parseStoredInteractionResult(requestCheckboxConfirmationResultSchema, row.result, row), } satisfies RequestCheckboxConfirmationInteraction; case "request_item_verdicts": return { ...base, kind: "request_item_verdicts", payload: requestItemVerdictsPayloadSchema.parse(row.payload), - result: row.result ? requestItemVerdictsResultSchema.parse(row.result) : null, + result: parseStoredInteractionResult(requestItemVerdictsResultSchema, row.result, row), } satisfies RequestItemVerdictsInteraction; default: throw unprocessable(`Unknown interaction kind: ${row.kind}`);