mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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:
1 parent
db72ad4c73
commit
994d6edcdd
2 files changed
+95
-2
No files matched your search
@@ -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 = [
|
||||
"",
|
||||
"# 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: "",
|
||||
})).resolves.toMatchObject({ title: "Error dialog", titleNeedsGeneration: true });
|
||||
await expect(issueService(server.db).create(f.companyId, {
|
||||
description: "",
|
||||
})).resolves.toMatchObject({ title: "Image", titleNeedsGeneration: true });
|
||||
await expect(issueService(server.db).create(f.companyId, {
|
||||
description: ".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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user