diff --git a/packages/shared/src/validators/approval.test.ts b/packages/shared/src/validators/approval.test.ts new file mode 100644 index 00000000..051d29b2 --- /dev/null +++ b/packages/shared/src/validators/approval.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + addApprovalCommentSchema, + requestApprovalRevisionSchema, + resolveApprovalSchema, +} from "./approval.js"; + +describe("approval validators", () => { + it("passes real line breaks through unchanged", () => { + expect(addApprovalCommentSchema.parse({ body: "Looks good\n\nApproved." }).body) + .toBe("Looks good\n\nApproved."); + expect(resolveApprovalSchema.parse({ decisionNote: "Decision\n\nApproved." }).decisionNote) + .toBe("Decision\n\nApproved."); + }); + + it("accepts null and omitted optional decision notes", () => { + expect(resolveApprovalSchema.parse({ decisionNote: null }).decisionNote).toBeNull(); + expect(resolveApprovalSchema.parse({}).decisionNote).toBeUndefined(); + expect(requestApprovalRevisionSchema.parse({ decisionNote: null }).decisionNote).toBeNull(); + expect(requestApprovalRevisionSchema.parse({}).decisionNote).toBeUndefined(); + }); + + it("normalizes escaped line breaks in approval comments and decision notes", () => { + expect(addApprovalCommentSchema.parse({ body: "Looks good\\n\\nApproved." }).body) + .toBe("Looks good\n\nApproved."); + expect(resolveApprovalSchema.parse({ decisionNote: "Decision\\n\\nApproved." }).decisionNote) + .toBe("Decision\n\nApproved."); + expect(requestApprovalRevisionSchema.parse({ decisionNote: "Decision\\r\\nRevise." }).decisionNote) + .toBe("Decision\nRevise."); + }); +}); diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index ae6b2eb3..ca704795 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { APPROVAL_TYPES } from "../constants.js"; +import { multilineTextSchema } from "./text.js"; export const createApprovalSchema = z.object({ type: z.enum(APPROVAL_TYPES), @@ -11,13 +12,13 @@ export const createApprovalSchema = z.object({ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ - decisionNote: z.string().optional().nullable(), + decisionNote: multilineTextSchema.optional().nullable(), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ - decisionNote: z.string().optional().nullable(), + decisionNote: multilineTextSchema.optional().nullable(), }); export type RequestApprovalRevision = z.infer; @@ -29,7 +30,7 @@ export const resubmitApprovalSchema = z.object({ export type ResubmitApproval = z.infer; export const addApprovalCommentSchema = z.object({ - body: z.string().min(1), + body: multilineTextSchema.pipe(z.string().min(1)), }); export type AddApprovalComment = z.infer; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts new file mode 100644 index 00000000..4fa908e4 --- /dev/null +++ b/packages/shared/src/validators/issue.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + addIssueCommentSchema, + createIssueSchema, + respondIssueThreadInteractionSchema, + suggestedTaskDraftSchema, + updateIssueSchema, + upsertIssueDocumentSchema, +} from "./issue.js"; + +describe("issue validators", () => { + it("passes real line breaks through unchanged", () => { + const parsed = createIssueSchema.parse({ + title: "Follow up PR", + description: "Line 1\n\nLine 2", + }); + + expect(parsed.description).toBe("Line 1\n\nLine 2"); + }); + + it("accepts null and omitted optional multiline issue fields", () => { + expect(createIssueSchema.parse({ title: "Follow up PR", description: null }).description) + .toBeNull(); + expect(createIssueSchema.parse({ title: "Follow up PR" }).description) + .toBeUndefined(); + expect(updateIssueSchema.parse({ comment: undefined }).comment) + .toBeUndefined(); + }); + + it("normalizes JSON-escaped line breaks in issue descriptions", () => { + const parsed = createIssueSchema.parse({ + title: "Follow up PR", + description: "PR: https://example.com/pr/1\\n\\nShip the follow-up.", + }); + + expect(parsed.description).toBe("PR: https://example.com/pr/1\n\nShip the follow-up."); + }); + + it("normalizes escaped line breaks in issue update comments", () => { + const parsed = updateIssueSchema.parse({ + comment: "Done\\n\\n- Verified the route", + }); + + expect(parsed.comment).toBe("Done\n\n- Verified the route"); + }); + + it("normalizes escaped line breaks in issue comment bodies", () => { + const parsed = addIssueCommentSchema.parse({ + body: "Progress update\\r\\n\\r\\nNext action.", + }); + + expect(parsed.body).toBe("Progress update\n\nNext action."); + }); + + it("normalizes escaped line breaks in generated task drafts", () => { + const parsed = suggestedTaskDraftSchema.parse({ + clientKey: "task-1", + title: "Follow up", + description: "Line 1\\n\\nLine 2", + }); + + expect(parsed.description).toBe("Line 1\n\nLine 2"); + }); + + it("normalizes escaped line breaks in thread summaries and documents", () => { + const response = respondIssueThreadInteractionSchema.parse({ + answers: [], + summaryMarkdown: "Summary\\n\\nNext action", + }); + const document = upsertIssueDocumentSchema.parse({ + format: "markdown", + body: "# Plan\\n\\nShip it", + }); + + expect(response.summaryMarkdown).toBe("Summary\n\nNext action"); + expect(document.body).toBe("# Plan\n\nShip it"); + }); +}); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b4451cf9..d8f58a83 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -10,6 +10,7 @@ import { ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, } from "../constants.js"; +import { multilineTextSchema } from "./text.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ "inherit", @@ -130,7 +131,7 @@ export const createIssueSchema = z.object({ blockedByIssueIds: z.array(z.string().uuid()).optional(), inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), - description: z.string().optional().nullable(), + description: multilineTextSchema.optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"), assigneeAgentId: z.string().uuid().optional().nullable(), @@ -168,7 +169,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ assigneeAgentId: z.string().trim().min(1).optional().nullable(), - comment: z.string().min(1).optional(), + comment: multilineTextSchema.pipe(z.string().min(1)).optional(), reviewRequest: issueReviewRequestSchema.optional().nullable(), reopen: z.boolean().optional(), resume: z.boolean().optional(), @@ -187,7 +188,7 @@ export const checkoutIssueSchema = z.object({ export type CheckoutIssue = z.infer; export const addIssueCommentSchema = z.object({ - body: z.string().min(1), + body: multilineTextSchema.pipe(z.string().min(1)), reopen: z.boolean().optional(), resume: z.boolean().optional(), interrupt: z.boolean().optional(), @@ -213,7 +214,7 @@ export const suggestedTaskDraftSchema = z.object({ parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), parentId: z.string().uuid().nullable().optional(), title: z.string().trim().min(1).max(240), - description: z.string().trim().max(20000).nullable().optional(), + description: multilineTextSchema.pipe(z.string().trim().max(20000)).nullable().optional(), priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), assigneeAgentId: z.string().uuid().nullable().optional(), assigneeUserId: z.string().trim().min(1).nullable().optional(), @@ -439,7 +440,7 @@ export type RejectIssueThreadInteraction = z.infer; @@ -462,7 +463,7 @@ export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS); export const upsertIssueDocumentSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), format: issueDocumentFormatSchema, - body: z.string().max(524288), + body: multilineTextSchema.pipe(z.string().max(524288)), changeSummary: z.string().trim().max(500).nullable().optional(), baseRevisionId: z.string().uuid().nullable().optional(), }); diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts new file mode 100644 index 00000000..322597a9 --- /dev/null +++ b/packages/shared/src/validators/text.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export function normalizeEscapedLineBreaks(value: string): string { + return value + .replace(/\\r\\n/g, "\n") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\n"); +} + +export const multilineTextSchema = z.string().transform(normalizeEscapedLineBreaks);