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;