feat(KB-621): add AI title summarization feature

- Add core AI summarization service with model selection hierarchy
- Modify task store to support title generation callback pattern
- Add POST /api/ai/summarize-title endpoint with rate limiting
- Wire up summarization in dashboard task creation flow
- Add AI Summarization settings UI section in dashboard
- Add comprehensive tests for summarization service and API
- Document auto-summarization settings in AGENTS.md
This commit is contained in:
gsxdsm
2026-03-31 18:59:02 -07:00
parent bc0cb27e61
commit e65e00537c
12 changed files with 1240 additions and 15 deletions

View File

@@ -22,6 +22,7 @@ import {
saveWorkspaceFileContent,
startPlanningStreaming,
fetchTasks,
summarizeTitle,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
@@ -1678,3 +1679,124 @@ describe("REFINE_ERROR_MESSAGES", () => {
});
});
// --- Summarize Title Tests ---
describe("summarizeTitle", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns title on successful response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
});
global.fetch = mockFetch;
const result = await summarizeTitle("a".repeat(200));
expect(result).toBe("Generated Title");
expect(mockFetch).toHaveBeenCalledWith(
"/api/ai/summarize-title",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "a".repeat(200), provider: undefined, modelId: undefined }),
})
);
});
it("sends provider and modelId when provided", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ title: "Generated Title" })),
});
global.fetch = mockFetch;
await summarizeTitle("a".repeat(200), "anthropic", "claude-sonnet-4-5");
expect(mockFetch).toHaveBeenCalledWith(
"/api/ai/summarize-title",
expect.objectContaining({
body: JSON.stringify({ description: "a".repeat(200), provider: "anthropic", modelId: "claude-sonnet-4-5" }),
})
);
});
it("throws descriptive error on 400 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Description too short" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("short")).rejects.toThrow("Invalid request: Description too short");
});
it("throws descriptive error on 429 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Rate limit exceeded" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Rate limit exceeded: Rate limit exceeded");
});
it("throws descriptive error on 503 response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "AI service unavailable" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("AI service temporarily unavailable: AI service unavailable");
});
it("throws generic error on other failure responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({ error: "Internal server error" })),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("Internal server error");
});
it("throws error for non-JSON responses", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html" }),
text: vi.fn().mockResolvedValue("<html>Not JSON</html>"),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned non-JSON response");
});
it("throws error when response has no title", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: vi.fn().mockResolvedValue(JSON.stringify({})),
});
global.fetch = mockFetch;
await expect(summarizeTitle("a".repeat(200))).rejects.toThrow("API returned empty title");
});
});