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:
5
.changeset/fn-6305-title-summarize-any-length.md
Normal file
5
.changeset/fn-6305-title-summarize-any-length.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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" +
|
||||
"<description>\n" +
|
||||
description +
|
||||
truncatedDescription +
|
||||
"\n</description>";
|
||||
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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user