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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-03 21:19:55 -05:00
1 parent db72ad4c73
commit 994d6edcdd
2 files changed
+95 -2

No files matched your search

+57 -1
View File
@@ -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<typeof import("chat")>();
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";
+38 -1
View File
@@ -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;