From 72661faf96d13143705b712a81f4458ce4a8ce9d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 13:12:55 -0700 Subject: [PATCH] FN-6305: allow title summarization for long descriptions Title summarization now accepts oversized task descriptions while bounding the model prompt. - Remove the 2000-character validation ceiling for summarize-title requests. - Truncate long descriptions only at model-input time with a dedicated exported cap. - Update route/API docs and tests for long-description behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6305-title-summarize-any-length.md | 5 +++ packages/core/src/__tests__/ai-summarize.test.ts | 43 ++++++++++++++++++---- packages/core/src/ai-summarize.ts | 34 +++++++++++------ packages/core/src/index.ts | 1 + packages/dashboard/app/api/legacy.ts | 2 +- .../src/__tests__/routes-planning.test.ts | 25 +++++++++++++ packages/dashboard/src/routes.ts | 2 +- 7 files changed, 92 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-6305 Fusion-Task-Lineage: 69a83173-ac85-489f-b4cc-d1a70b6d25eb --- .../fn-6305-title-summarize-any-length.md | 5 +++ .../core/src/__tests__/ai-summarize.test.ts | 43 ++++++++++++++++--- packages/core/src/ai-summarize.ts | 34 ++++++++++----- packages/core/src/index.ts | 1 + packages/dashboard/app/api/legacy.ts | 2 +- .../src/__tests__/routes-planning.test.ts | 25 +++++++++++ packages/dashboard/src/routes.ts | 2 +- 7 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 .changeset/fn-6305-title-summarize-any-length.md diff --git a/.changeset/fn-6305-title-summarize-any-length.md b/.changeset/fn-6305-title-summarize-any-length.md new file mode 100644 index 0000000000..961d5a807f --- /dev/null +++ b/.changeset/fn-6305-title-summarize-any-length.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters. diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index 9c2fbbbf7f..a977332d4d 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -22,6 +22,7 @@ import { MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT, MAX_DESCRIPTION_LENGTH, + MAX_TITLE_SUMMARIZE_INPUT_LENGTH, MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, @@ -53,6 +54,7 @@ describe("ai-summarize", () => { it("should have correct length limits", () => { expect(MIN_DESCRIPTION_LENGTH).toBe(201); expect(MAX_DESCRIPTION_LENGTH).toBe(2000); + expect(MAX_TITLE_SUMMARIZE_INPUT_LENGTH).toBe(4000); expect(MAX_TITLE_LENGTH).toBe(60); }); @@ -89,21 +91,21 @@ describe("ai-summarize", () => { expect(() => validateDescription(desc)).toThrow("at least 201 characters"); }); - it("should throw for description too long", () => { - const desc = "a".repeat(2001); - expect(() => validateDescription(desc)).toThrow(ValidationError); - expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters"); - }); - it("should accept description at minimum boundary", () => { const desc = "a".repeat(201); expect(validateDescription(desc)).toBe(desc); }); - it("should accept description at maximum boundary", () => { + it("should accept description at historical maximum boundary", () => { const desc = "a".repeat(2000); expect(validateDescription(desc)).toBe(desc); }); + + it("should accept descriptions longer than the historical maximum", () => { + const desc = "a".repeat(5000); + expect(() => validateDescription(desc)).not.toThrow(); + expect(validateDescription(desc)).toBe(desc); + }); }); // ── Rate Limiting ────────────────────────────────────────────────────────── @@ -206,6 +208,33 @@ describe("ai-summarize", () => { expect(prompt.mock.calls[0][0]).toContain("Do not call any tools"); }); + it("summarizes long descriptions with bounded prompt input", async () => { + const prompt = vi.fn().mockResolvedValue(undefined); + getFnAgentMock.mockResolvedValue(() => + Promise.resolve({ + session: { + prompt, + dispose: vi.fn(), + state: { + messages: [ + { role: "assistant", content: "Summarize long description" }, + ], + }, + }, + }) + ); + const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250); + + const title = await summarizeTitle(description, "/tmp"); + + expect(title).toBe("Summarize long description"); + expect(prompt).toHaveBeenCalledTimes(1); + const promptText = prompt.mock.calls[0][0] as string; + expect(promptText).toContain("…(truncated)"); + expect(promptText).not.toContain("tail"); + expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250); + }); + it("strips chatty preamble + markdown from AI response (FN-3057 regression)", async () => { // Reproduces the FN-3057 incident: model wrote a chat-style reply // ("Created **FN-3058** with the full spec…") that was sliced mid-word diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index a15990bad7..23c8ace7fc 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -8,7 +8,7 @@ * Features: * - Rate limiting per IP (10 requests per hour) * - Dynamic import of @fusion/engine for AI agent creation - * - Text length validation (201-2000 characters) + * - Text length validation (minimum 201 characters; model input is truncated) */ import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js"; @@ -32,9 +32,21 @@ Your ONLY job is to create a concise title (max 60 characters) that summarizes t - Maximum 60 characters - Focus on the main goal or deliverable of the task`; -/** Maximum description length in characters */ +/** + * Historical maximum accepted description length in characters. + * + * @deprecated Title summarization now accepts descriptions of any length; + * use MAX_TITLE_SUMMARIZE_INPUT_LENGTH for the bounded model-input cap. + */ export const MAX_DESCRIPTION_LENGTH = 2000; +/** + * Maximum input length for title summarization. Descriptions can be very large; + * we truncate before sending so the prompt stays bounded while preserving the + * long-input API behavior. + */ +export const MAX_TITLE_SUMMARIZE_INPUT_LENGTH = 4000; + /** Minimum description length for summarization in characters */ export const MIN_DESCRIPTION_LENGTH = 201; @@ -178,17 +190,13 @@ export function validateDescription(description: unknown): string { throw new ValidationError("description must be a string"); } - // Validate description length + // Validate description length floor. There is intentionally no upper bound: + // runTitleSummarizer truncates model input before prompting. if (description.length < MIN_DESCRIPTION_LENGTH) { throw new ValidationError( `description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization` ); } - if (description.length > MAX_DESCRIPTION_LENGTH) { - throw new ValidationError( - `description must not exceed ${MAX_DESCRIPTION_LENGTH} characters` - ); - } return description; } @@ -246,12 +254,16 @@ async function runTitleSummarizer( // Wrap the user-supplied description in a delimiter so the model treats it // as content to summarize, not as instructions to follow. Belt-and-suspenders // alongside the system-prompt guardrails and the engine's readonly tool - // isolation. + // isolation. Truncate before prompt construction so arbitrarily long task + // descriptions cannot produce unbounded model input. + const truncatedDescription = description.length > MAX_TITLE_SUMMARIZE_INPUT_LENGTH + ? description.slice(0, MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "\n…(truncated)" + : description; const wrappedPrompt = "Summarize the following task description into a title (≤60 chars). " + "Output ONLY the title text on a single line. Do not call any tools.\n\n" + "\n" + - description + + truncatedDescription + "\n"; await agentResult.session.prompt(wrappedPrompt); @@ -317,7 +329,7 @@ async function runTitleSummarizer( /** * Summarize a task description into a concise title using AI. - * @param description - The task description to summarize (must be 201-2000 chars) + * @param description - The task description to summarize (must be >200 chars; model input is truncated) * @param rootDir - Project root directory for AI agent context * @param provider - Optional AI model provider (e.g., "anthropic") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ee0e36479..baa152807f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1006,6 +1006,7 @@ export { MAX_COMMIT_SUBJECT_LENGTH, DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS, MAX_DESCRIPTION_LENGTH, + MAX_TITLE_SUMMARIZE_INPUT_LENGTH, MIN_DESCRIPTION_LENGTH, MAX_TITLE_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b8a579d067..2cbd37729a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -6439,7 +6439,7 @@ export interface SummarizeTitleResponse { } /** Summarize a task description into a concise title using AI. - * @param description - The task description to summarize (must be 201-2000 chars) + * @param description - The task description to summarize (must be >200 chars; model input is truncated) * @param provider - Optional AI model provider (e.g., "anthropic") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") * @param projectId - Optional project ID for scoped settings resolution diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 8ff8c55b39..eb0d77ed1d 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -3757,6 +3757,31 @@ describe("POST /api/ai/summarize-title", () => { ); }); + it("accepts descriptions longer than 2000 characters", async () => { + const fusionCore = await import("@fusion/core"); + const summarizeTitleSpy = vi + .spyOn(fusionCore, "summarizeTitle") + .mockResolvedValueOnce("Generated title"); + + const description = "x".repeat(5000); + const res = await REQUEST( + buildApp(), + "POST", + "/api/ai/summarize-title", + JSON.stringify({ description }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ title: "Generated title" }); + expect(summarizeTitleSpy).toHaveBeenCalledWith( + description, + "/test/project", + undefined, + undefined, + ); + }); + it("emits structured diagnostics for unexpected summarize failures", async () => { const diagnostics = captureDiagnostics(); const fusionCore = await import("@fusion/core"); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a73e15dcc5..361d12325e 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1848,6 +1848,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout * Returns: { title: string } * * Generates a concise title (≤60 characters) from descriptions longer than 200 characters. + * Long descriptions are accepted; core truncates model input before prompting. * Rate limited: 10 requests per hour per IP */ router.post("/ai/summarize-title", async (req, res) => { @@ -1863,7 +1864,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout summarizeTitle, validateDescription, MIN_DESCRIPTION_LENGTH, - MAX_DESCRIPTION_LENGTH: _MAX_DESCRIPTION_LENGTH, RateLimitError: _RateLimitError4, ValidationError: _ValidationError2, AiServiceError: _AiServiceError2,