diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cbb2b9e49bf..30f5ca5ef22 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2353,6 +2353,7 @@ export { type EnvironmentCustomImageTerminalSessionToken, } from "./validators/environment-custom-images.js"; export * from "./validators/skill-policy.js"; +export { normalizeMarkdownBody } from "./validators/text.js"; export { FEATURE_TIERS, INSTANCE_FEATURE_CATALOG, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d93c6649dd6..df169578136 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -914,4 +914,5 @@ export { type RevokeToolTrustRule, } from "./tool-access.js"; export * from "./skill-policy.js"; +export { normalizeMarkdownBody } from "./text.js"; export * from "./app-definition.js"; diff --git a/packages/shared/src/validators/text.test.ts b/packages/shared/src/validators/text.test.ts index ee30f51d8db..90950765bd8 100644 --- a/packages/shared/src/validators/text.test.ts +++ b/packages/shared/src/validators/text.test.ts @@ -57,6 +57,11 @@ describe("normalizeMidLineHeadings", () => { expect(normalizeMidLineHeadings(body)).toBe(body); }); + it("does not close a long fence with a shorter run", () => { + const body = "````markdown\n```\ncode ## comment\n````"; + expect(normalizeMidLineHeadings(body)).toBe(body); + }); + it("is a no-op when there is no `#` at all", () => { const body = "Just some prose with no markers."; expect(normalizeMidLineHeadings(body)).toBe(body); @@ -71,8 +76,8 @@ describe("normalizeMarkdownBody / multilineTextSchema", () => { ); }); - it("multilineTextSchema self-heals a collapsed plan body on write", () => { + it("multilineTextSchema leaves unrelated mid-line markers unchanged", () => { const parsed = multilineTextSchema.parse("plan pack)## The goal, restated"); - expect(parsed).toBe("plan pack)\n\n## The goal, restated"); + expect(parsed).toBe("plan pack)## The goal, restated"); }); }); diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts index e00eade1155..1ecf46704bf 100644 --- a/packages/shared/src/validators/text.ts +++ b/packages/shared/src/validators/text.ts @@ -43,6 +43,7 @@ export function normalizeMidLineHeadings(value: string): string { const lines = value.split("\n"); let inFence = false; let fenceChar = ""; + let fenceLength = 0; const out: string[] = []; for (const line of lines) { const fenceMatch = FENCE_LINE.exec(line); @@ -51,9 +52,11 @@ export function normalizeMidLineHeadings(value: string): string { if (!inFence) { inFence = true; fenceChar = marker[0]!; - } else if (marker[0] === fenceChar) { + fenceLength = marker.length; + } else if (marker[0] === fenceChar && marker.length >= fenceLength) { inFence = false; fenceChar = ""; + fenceLength = 0; } out.push(line); continue; @@ -67,4 +70,4 @@ export function normalizeMarkdownBody(value: string): string { return normalizeMidLineHeadings(normalizeEscapedLineBreaks(value)); } -export const multilineTextSchema = z.string().transform(normalizeMarkdownBody); +export const multilineTextSchema = z.string().transform(normalizeEscapedLineBreaks); diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts index 9584c2ebb1c..bddf1deb3c6 100644 --- a/server/src/__tests__/document-annotation-routes.test.ts +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -281,6 +281,32 @@ describe("document annotation routes", () => { expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); + it("heals collapsed headings only for the plan document", async () => { + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + title: "Document API", + status: "in_progress", + assigneeAgentId: null, + }); + + await request(await createApp()) + .put(`/api/issues/${issueId}/documents/plan`) + .send({ format: "markdown", body: "Intro)## Goal" }) + .expect(200); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenLastCalledWith( + expect.objectContaining({ body: "Intro)\n\n## Goal" }), + ); + + await request(await createApp()) + .put(`/api/issues/${issueId}/documents/notes`) + .send({ format: "markdown", body: "Intro)## Legitimate inline text" }) + .expect(200); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenLastCalledWith( + expect.objectContaining({ body: "Intro)## Legitimate inline text" }), + ); + }); + it("queues one bounded recovery when a board document revision expires the final review interactions", async () => { const assigneeAgentId = "99999999-9999-4999-8999-999999999999"; mockIssueService.getById.mockResolvedValue({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index beb6459dd62..e436d148537 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -68,6 +68,7 @@ import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, isUuidLike, + normalizeMarkdownBody, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompactIssue, type CompanySearchExtractQuery, @@ -6676,7 +6677,7 @@ export function issueRoutes( key: keyParsed.data, title: req.body.title ?? null, format: req.body.format, - body: req.body.body, + body: keyParsed.data === "plan" ? normalizeMarkdownBody(req.body.body) : req.body.body, changeSummary: req.body.changeSummary ?? null, baseRevisionId: req.body.baseRevisionId ?? null, createdByAgentId: actor.agentId ?? null,