From 994d6edcdd4e15d5f9cc5cf8c135ac599104b86a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:19:55 -0500 Subject: [PATCH] Fix Markdown in provisional task titles (#15047) ## Thinking Path > - Paperclip uses issues to organize work for AI agents. > - Paperclip creates a provisional issue title from the issue description. > - A description can start with a Markdown image or other Markdown syntax. > - The old title logic truncated the raw Markdown source. > - This pull request parses Markdown before it truncates the title. > - The benefit is a short and readable provisional title. ## Linked Issues or Issue Description **What happened?** When an issue description started with a Markdown image, Paperclip used the raw image syntax and URL in the provisional title. Other common Markdown markers could also appear in the title. **Expected behavior** Paperclip must remove Markdown syntax before it creates the 120-character provisional title. It must keep useful text, such as link labels and inline code content. **Steps to reproduce** 1. Create an issue without an explicit title. 2. Start the issue description with a Markdown image. 3. Add Markdown text after the image. 4. Observe that the provisional title contains raw Markdown syntax or an image URL. **Paperclip version or commit** The problem reproduces on `master` before this change. **Deployment mode** Local development with the embedded PGlite database. ## What Changed - Parse the description and remove image nodes before title generation. - Convert the remaining Markdown to plain text before the 120-character limit. - Keep link labels, inline code, and identifier punctuation. - Use image alt text, or `Image`, for an image-only description. - Fall back to a simple 120-character title if Markdown parsing or conversion fails. - Add regression tests for image and inline-code titles, parser failures, and text-conversion failures. ## Verification - `./node_modules/.bin/vitest run server/src/__tests__/task-title-routes.test.ts` - `./node_modules/.bin/tsc -p server/tsconfig.json --noEmit` - Full GitHub CI matrix passed on commit `663f3a0517c9e450f174b6c37970756457067ba5`. - Greptile reviewed the latest commit with a 5/5 confidence score and no unresolved findings. ## Risks - Low risk. This change only affects generated provisional titles. It does not change explicit titles or full issue descriptions. - The implementation uses the Markdown parser and plain-text converter that are already present in the server. > This is a focused bug fix. It does not add planned core work from `ROADMAP.md`. ## Model Used - OpenAI Codex with GPT-5. The run used reasoning, tool use, code execution, and repository access. The runtime did not expose the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/__tests__/task-title-routes.test.ts | 58 ++++++++++++++++++- server/src/services/issues.ts | 39 ++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/task-title-routes.test.ts b/server/src/__tests__/task-title-routes.test.ts index 77ab74d538..5146729da0 100644 --- a/server/src/__tests__/task-title-routes.test.ts +++ b/server/src/__tests__/task-title-routes.test.ts @@ -1,5 +1,15 @@ import { randomUUID } from "node:crypto"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import * as chat from "chat"; + +vi.mock("chat", async importOriginal => { + const original = await importOriginal(); + return { + ...original, + parseMarkdown: vi.fn(original.parseMarkdown), + markdownToPlainText: vi.fn(original.markdownToPlainText), + }; +}); import { and, eq } from "drizzle-orm"; import { activityLog, externalObjectMentions, heartbeatRuns, issues } from "@paperclipai/db"; import { createChildIssueSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared"; @@ -49,6 +59,52 @@ describe("task titles", () => { expect(setIssueTitleSchema.safeParse({ title: "x".repeat(241) }).success).toBe(false); }); + it("removes leading Markdown before truncating a provisional title", async () => { + const f = await server.fixture(); + const description = [ + "![Screenshot](https://example.com/a-very-long-image-name.png)", + "# Fix the [sign-in redirect](https://example.com/issues/42)", + "Keep `returnTo` working with **saved sessions**.", + ].join("\n\n"); + const result = await issueService(server.db).create(f.companyId, { description }); + expect(result).toMatchObject({ + title: "Fix the sign-in redirect Keep returnTo working with saved sessions.", + description, + titleNeedsGeneration: true, + }); + await expect(issueService(server.db).create(f.companyId, { + description: "![Error dialog](https://example.com/error.png)", + })).resolves.toMatchObject({ title: "Error dialog", titleNeedsGeneration: true }); + await expect(issueService(server.db).create(f.companyId, { + description: "![](https://example.com/image.png)", + })).resolves.toMatchObject({ title: "Image", titleNeedsGeneration: true }); + await expect(issueService(server.db).create(f.companyId, { + description: "![Map](https://example.com/Map_(1).png)", + })).resolves.toMatchObject({ title: "Map", titleNeedsGeneration: true }); + await expect(issueService(server.db).create(f.companyId, { + description: "Fix `set_task_title`", + })).resolves.toMatchObject({ title: "Fix set_task_title", titleNeedsGeneration: true }); + await expect(issueService(server.db).create(f.companyId, { + description: "![Screenshot][img]\n\n[img]: https://example.com/image.png\n\nFix login", + })).resolves.toMatchObject({ title: "Fix login", titleNeedsGeneration: true }); + }); + + it.each(["parseMarkdown", "markdownToPlainText"] as const)("falls back to a simple title if %s throws", async parser => { + const f = await server.fixture(); + const description = ` Fix the login flow after a failed parser\n${"long ".repeat(30)}`; + vi.mocked(chat[parser]).mockImplementationOnce(() => { throw new Error("parser failed"); }); + try { + const result = await issueService(server.db).create(f.companyId, { description }); + expect(result).toMatchObject({ + title: description.trim().replace(/\s+/g, " ").slice(0, 120), + description, + titleNeedsGeneration: true, + }); + } finally { + vi.mocked(chat[parser]).mockClear(); + } + }); + it("creates prompt-only children and still accepts explicit child titles", async () => { const f = await server.fixture(); const description = "Investigate the child sign-in redirect"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index a214dbd595..fbf9868285 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -9,6 +9,7 @@ import { executionProjectionsForRuns } from "./execution-projection.js"; import type { ExecutionProjection } from "@paperclipai/shared"; import { Buffer } from "node:buffer"; import { createHash, randomUUID } from "node:crypto"; +import { markdownToPlainText, parseMarkdown } from "chat"; import { and, asc, @@ -6621,6 +6622,40 @@ export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); + function provisionalTitleFromDescription(description: string) { + const simpleTitle = description.trim().replace(/\s+/g, " ").slice(0, 120); + try { + type MarkdownNode = { + type: string; + alt?: string | null; + children?: MarkdownNode[]; + position?: { start: { offset?: number }; end: { offset?: number } }; + }; + const imageRanges: Array<{ start: number; end: number }> = []; + const imageAlts: string[] = []; + const visit = (node: MarkdownNode) => { + if (node.type === "image" || node.type === "imageReference") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) imageRanges.push({ start, end }); + if (node.alt?.trim()) imageAlts.push(node.alt.trim()); + } + node.children?.forEach(visit); + }; + visit(parseMarkdown(description) as MarkdownNode); + const withoutImages = imageRanges + .sort((a, b) => b.start - a.start) + .reduce((text, range) => `${text.slice(0, range.start)} ${text.slice(range.end)}`, description); + const plainText = markdownToPlainText(withoutImages).trim().replace(/\s+/g, " "); + const fallback = imageRanges.length > 0 + ? imageAlts.join(" ") || "Image" + : simpleTitle; + return (plainText || fallback).slice(0, 120); + } catch { + return simpleTitle; + } + } + function normalizeCreateIssueTitle(title: string) { return title.trim().replace(/\s+/g, " ").toLowerCase(); } @@ -9759,7 +9794,9 @@ export function issueService(db: Db) { ...issueData } = data; const explicitTitle = issueData.title?.trim(); - const provisionalTitle = issueData.description?.trim().replace(/\s+/g, " ").slice(0, 120); + const provisionalTitle = issueData.description + ? provisionalTitleFromDescription(issueData.description) + : undefined; const resolvedTitle = explicitTitle || provisionalTitle; if (!resolvedTitle) throw unprocessable("Provide a title or task description"); const titleNeedsGeneration = !explicitTitle;