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
This commit is contained in:
gsxdsm
2026-06-12 13:12:55 -07:00
parent 283adce4d0
commit 72661faf96
7 changed files with 92 additions and 20 deletions

View File

@@ -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.

View File

@@ -22,6 +22,7 @@ import {
MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT, MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
COMMIT_BODY_SYSTEM_PROMPT, COMMIT_BODY_SYSTEM_PROMPT,
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MAX_TITLE_SUMMARIZE_INPUT_LENGTH,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,
MAX_MERGE_COMMIT_SUMMARY_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH,
@@ -53,6 +54,7 @@ describe("ai-summarize", () => {
it("should have correct length limits", () => { it("should have correct length limits", () => {
expect(MIN_DESCRIPTION_LENGTH).toBe(201); expect(MIN_DESCRIPTION_LENGTH).toBe(201);
expect(MAX_DESCRIPTION_LENGTH).toBe(2000); expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
expect(MAX_TITLE_SUMMARIZE_INPUT_LENGTH).toBe(4000);
expect(MAX_TITLE_LENGTH).toBe(60); expect(MAX_TITLE_LENGTH).toBe(60);
}); });
@@ -89,21 +91,21 @@ describe("ai-summarize", () => {
expect(() => validateDescription(desc)).toThrow("at least 201 characters"); 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", () => { it("should accept description at minimum boundary", () => {
const desc = "a".repeat(201); const desc = "a".repeat(201);
expect(validateDescription(desc)).toBe(desc); 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); const desc = "a".repeat(2000);
expect(validateDescription(desc)).toBe(desc); 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 ────────────────────────────────────────────────────────── // ── Rate Limiting ──────────────────────────────────────────────────────────
@@ -206,6 +208,33 @@ describe("ai-summarize", () => {
expect(prompt.mock.calls[0][0]).toContain("Do not call any tools"); 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 () => { it("strips chatty preamble + markdown from AI response (FN-3057 regression)", async () => {
// Reproduces the FN-3057 incident: model wrote a chat-style reply // Reproduces the FN-3057 incident: model wrote a chat-style reply
// ("Created **FN-3058** with the full spec…") that was sliced mid-word // ("Created **FN-3058** with the full spec…") that was sliced mid-word

View File

@@ -8,7 +8,7 @@
* Features: * Features:
* - Rate limiting per IP (10 requests per hour) * - Rate limiting per IP (10 requests per hour)
* - Dynamic import of @fusion/engine for AI agent creation * - 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"; 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 - Maximum 60 characters
- Focus on the main goal or deliverable of the task`; - 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; 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 */ /** Minimum description length for summarization in characters */
export const MIN_DESCRIPTION_LENGTH = 201; export const MIN_DESCRIPTION_LENGTH = 201;
@@ -178,17 +190,13 @@ export function validateDescription(description: unknown): string {
throw new ValidationError("description must be a 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) { if (description.length < MIN_DESCRIPTION_LENGTH) {
throw new ValidationError( throw new ValidationError(
`description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization` `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; return description;
} }
@@ -246,12 +254,16 @@ async function runTitleSummarizer(
// Wrap the user-supplied description in a delimiter so the model treats it // 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 // as content to summarize, not as instructions to follow. Belt-and-suspenders
// alongside the system-prompt guardrails and the engine's readonly tool // 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 = const wrappedPrompt =
"Summarize the following task description into a title (≤60 chars). " + "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" + "Output ONLY the title text on a single line. Do not call any tools.\n\n" +
"<description>\n" + "<description>\n" +
description + truncatedDescription +
"\n</description>"; "\n</description>";
await agentResult.session.prompt(wrappedPrompt); await agentResult.session.prompt(wrappedPrompt);
@@ -317,7 +329,7 @@ async function runTitleSummarizer(
/** /**
* Summarize a task description into a concise title using AI. * 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 rootDir - Project root directory for AI agent context
* @param provider - Optional AI model provider (e.g., "anthropic") * @param provider - Optional AI model provider (e.g., "anthropic")
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")

View File

@@ -1006,6 +1006,7 @@ export {
MAX_COMMIT_SUBJECT_LENGTH, MAX_COMMIT_SUBJECT_LENGTH,
DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS, DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS,
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MAX_TITLE_SUMMARIZE_INPUT_LENGTH,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH, MAX_TITLE_LENGTH,
MAX_MERGE_COMMIT_SUMMARY_LENGTH, MAX_MERGE_COMMIT_SUMMARY_LENGTH,

View File

@@ -6439,7 +6439,7 @@ export interface SummarizeTitleResponse {
} }
/** Summarize a task description into a concise title using AI. /** 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 provider - Optional AI model provider (e.g., "anthropic")
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5") * @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
* @param projectId - Optional project ID for scoped settings resolution * @param projectId - Optional project ID for scoped settings resolution

View File

@@ -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 () => { it("emits structured diagnostics for unexpected summarize failures", async () => {
const diagnostics = captureDiagnostics(); const diagnostics = captureDiagnostics();
const fusionCore = await import("@fusion/core"); const fusionCore = await import("@fusion/core");

View File

@@ -1848,6 +1848,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Returns: { title: string } * Returns: { title: string }
* *
* Generates a concise title (≤60 characters) from descriptions longer than 200 characters. * 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 * Rate limited: 10 requests per hour per IP
*/ */
router.post("/ai/summarize-title", async (req, res) => { router.post("/ai/summarize-title", async (req, res) => {
@@ -1863,7 +1864,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
summarizeTitle, summarizeTitle,
validateDescription, validateDescription,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
MAX_DESCRIPTION_LENGTH: _MAX_DESCRIPTION_LENGTH,
RateLimitError: _RateLimitError4, RateLimitError: _RateLimitError4,
ValidationError: _ValidationError2, ValidationError: _ValidationError2,
AiServiceError: _AiServiceError2, AiServiceError: _AiServiceError2,