feat(KB-279): add AI-powered async title generation for tasks
- Add async generateTitle() using AI to create concise task titles from descriptions\n- Update createTask() to generate titles asynchronously when not provided\n- Add dynamic import pattern for @kb/engine to enable testability\n- Update store tests with comprehensive coverage for AI title generation\n- Add changeset for the title generation fix
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { TaskStore } from "./store.js";
|
||||
import { readFile, writeFile, mkdir, rm, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -6,6 +6,30 @@ import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Task } from "./types.js";
|
||||
|
||||
// Mock @kb/engine for title generation tests
|
||||
const mockDispose = vi.fn();
|
||||
const mockPrompt = vi.fn();
|
||||
let mockAgentResponse = "AI Generated Title";
|
||||
|
||||
vi.mock("@kb/engine", () => ({
|
||||
createKbAgent: vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
session: {
|
||||
prompt: mockPrompt,
|
||||
get state() {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: "test" },
|
||||
{ role: "assistant", content: mockAgentResponse },
|
||||
],
|
||||
};
|
||||
},
|
||||
dispose: mockDispose,
|
||||
},
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
||||
}
|
||||
@@ -62,14 +86,13 @@ describe("TaskStore", () => {
|
||||
|
||||
describe("prompt generation", () => {
|
||||
it("triage task without title does not duplicate description in PROMPT.md", async () => {
|
||||
const task = await store.createTask({ description: "Fix the login bug" });
|
||||
const task = await store.createTask({ description: "Fix the login bug on the settings page" });
|
||||
const detail = await store.getTask(task.id);
|
||||
|
||||
// Heading should include auto-generated title from description
|
||||
expect(detail.prompt).toMatch(/^# KB-001: Fix the login bug\n/);
|
||||
// Description appears exactly once
|
||||
const count = detail.prompt.split("Fix the login bug").length - 1;
|
||||
expect(count).toBe(2); // Once in heading, once in body
|
||||
// Heading should include AI-generated title (mock returns "AI Generated Title")
|
||||
expect(detail.prompt).toMatch(/^# KB-001: AI Generated Title\n/);
|
||||
// Description appears exactly once in body (not duplicated in heading)
|
||||
expect(detail.prompt).toContain("Fix the login bug on the settings page");
|
||||
});
|
||||
|
||||
it("triage task with title uses title in heading and description in body", async () => {
|
||||
@@ -2403,95 +2426,155 @@ describe("TaskStore", () => {
|
||||
// ── Title Generation Tests ───────────────────────────────────────
|
||||
|
||||
describe("title generation from description", () => {
|
||||
it("generates title from description when title is not provided", async () => {
|
||||
beforeEach(async () => {
|
||||
// Reset mock to successful response by default
|
||||
mockAgentResponse = "AI Generated Title";
|
||||
mockDispose.mockClear();
|
||||
mockPrompt.mockClear();
|
||||
// Reset the createKbAgent mock to default behavior
|
||||
const engineModule = "@kb/engine";
|
||||
const { createKbAgent } = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
session: {
|
||||
prompt: mockPrompt,
|
||||
get state() {
|
||||
return {
|
||||
messages: [
|
||||
{ role: "user", content: "test" },
|
||||
{ role: "assistant", content: mockAgentResponse },
|
||||
],
|
||||
};
|
||||
},
|
||||
dispose: mockDispose,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses AI to generate title for longer descriptions", async () => {
|
||||
const task = await store.createTask({ description: "Fix the login bug on the settings page" });
|
||||
|
||||
expect(task.title).toBe("Fix the login bug on the settings page");
|
||||
expect(task.title).toBeTruthy();
|
||||
// AI should have been called and returned the mock response
|
||||
expect(mockPrompt).toHaveBeenCalledWith(expect.stringContaining("Fix the login bug on the settings page"));
|
||||
expect(task.title).toBe("AI Generated Title");
|
||||
|
||||
// Verify persisted to disk
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.title).toBe("Fix the login bug on the settings page");
|
||||
expect(fetched.title).toBe("AI Generated Title");
|
||||
});
|
||||
|
||||
it("short-circuits for short descriptions (3 words or less)", async () => {
|
||||
// These should bypass AI and use description as-is
|
||||
const task1 = await store.createTask({ description: "Fix bug" });
|
||||
expect(task1.title).toBe("Fix bug");
|
||||
expect(mockPrompt).not.toHaveBeenCalled();
|
||||
|
||||
mockPrompt.mockClear();
|
||||
|
||||
const task2 = await store.createTask({ description: "Refactoring" });
|
||||
expect(task2.title).toBe("Refactoring");
|
||||
expect(mockPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses AI for 3-word descriptions exceeding 60 chars", async () => {
|
||||
// 3 words but >60 chars should go through AI
|
||||
const longWords = "supercalifragilisticexpialidocious pneumonoultramicroscopicsilicovolcanoconiosis floccinaucinihilipilification";
|
||||
expect(longWords.length).toBeGreaterThan(60);
|
||||
|
||||
const task = await store.createTask({ description: longWords });
|
||||
expect(mockPrompt).toHaveBeenCalled();
|
||||
expect(task.title).toBe("AI Generated Title");
|
||||
});
|
||||
|
||||
it("generates title from description when title is empty string", async () => {
|
||||
const task = await store.createTask({ title: "", description: "Implement caching layer for API responses" });
|
||||
|
||||
expect(task.title).toBe("Implement caching layer for API responses");
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.title).toBe("Implement caching layer for API responses");
|
||||
expect(mockPrompt).toHaveBeenCalled();
|
||||
expect(task.title).toBe("AI Generated Title");
|
||||
});
|
||||
|
||||
it("generates title from description when title is whitespace only", async () => {
|
||||
const task = await store.createTask({ title: " ", description: "Add dark mode support to the dashboard" });
|
||||
|
||||
expect(task.title).toBe("Add dark mode support to the dashboard");
|
||||
expect(mockPrompt).toHaveBeenCalled();
|
||||
expect(task.title).toBe("AI Generated Title");
|
||||
});
|
||||
|
||||
it("uses provided title when available (does not override)", async () => {
|
||||
it("uses provided title when available (does not call AI)", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "Custom Title",
|
||||
description: "This is the description that should not become the title",
|
||||
});
|
||||
|
||||
expect(task.title).toBe("Custom Title");
|
||||
expect(mockPrompt).not.toHaveBeenCalled();
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.title).toBe("Custom Title");
|
||||
});
|
||||
|
||||
it("handles very long descriptions gracefully (truncates to ~50 chars)", async () => {
|
||||
const longDescription = "This is a very long description with many words that should be truncated to around fifty characters when generating the title automatically";
|
||||
const task = await store.createTask({ description: longDescription });
|
||||
it("returns empty title when AI fails", async () => {
|
||||
// Simulate AI failure by making createKbAgent throw
|
||||
const engineModule = "@kb/engine";
|
||||
const { createKbAgent } = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent.mockImplementation(() => Promise.reject(new Error("AI failure")));
|
||||
|
||||
// Should be truncated to ~50 chars with 8-10 words
|
||||
expect(task.title!.length).toBeLessThanOrEqual(55);
|
||||
expect(task.title).toBe("This is a very long description with many words");
|
||||
const task = await store.createTask({ description: "Some long description that needs AI summarization" });
|
||||
|
||||
expect(task.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles short descriptions (less than 3 words)", async () => {
|
||||
const task = await store.createTask({ description: "Fix bug" });
|
||||
it("returns empty title when AI returns empty response", async () => {
|
||||
mockAgentResponse = "";
|
||||
|
||||
expect(task.title).toBe("Fix bug");
|
||||
const task = await store.createTask({ description: "Some long description that needs AI summarization" });
|
||||
|
||||
expect(task.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles single word descriptions", async () => {
|
||||
const task = await store.createTask({ description: "Refactoring" });
|
||||
it("cleans quotes from AI-generated titles", async () => {
|
||||
// Override the mock to return a quoted title
|
||||
const engineModule = "@kb/engine";
|
||||
const { createKbAgent } = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
session: {
|
||||
prompt: mockPrompt,
|
||||
state: {
|
||||
messages: [
|
||||
{ role: "user", content: "test" },
|
||||
{ role: "assistant", content: '"Quoted Title"' },
|
||||
],
|
||||
},
|
||||
dispose: mockDispose,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(task.title).toBe("Refactoring");
|
||||
// Use a description with >3 words to ensure AI is called
|
||||
const task = await store.createTask({ description: "Some long description with many words to trigger AI" });
|
||||
|
||||
expect(task.title).toBe("Quoted Title");
|
||||
});
|
||||
|
||||
it("handles descriptions with special characters", async () => {
|
||||
const task = await store.createTask({ description: "Fix \$\$\$ bug @ home-page (urgent!)" });
|
||||
it("handles descriptions with special characters via AI", async () => {
|
||||
const task = await store.createTask({ description: "Fix $$$ bug @ home-page (urgent!)" });
|
||||
|
||||
// Should extract alphanumeric words, dropping special chars
|
||||
expect(task.title).toBe("Fix bug home-page urgent");
|
||||
});
|
||||
|
||||
it("handles descriptions with only special characters (fallback)", async () => {
|
||||
const task = await store.createTask({ description: "!!! @@@ ###" });
|
||||
|
||||
// Should fallback to first 50 chars of normalized text
|
||||
expect(task.title).toBe("!!! @@@ ###");
|
||||
expect(mockPrompt).toHaveBeenCalled();
|
||||
expect(task.title).toBe("AI Generated Title");
|
||||
});
|
||||
|
||||
it("handles empty description gracefully (should throw)", async () => {
|
||||
await expect(store.createTask({ description: "" })).rejects.toThrow("Description is required");
|
||||
});
|
||||
|
||||
it("preserves hyphenated and apostrophe words correctly", async () => {
|
||||
const task = await store.createTask({ description: "Fix user-input validation for today's date" });
|
||||
|
||||
expect(task.title).toBe("Fix user-input validation for today's date");
|
||||
});
|
||||
|
||||
it("includes generated title in PROMPT.md heading for triage tasks", async () => {
|
||||
const task = await store.createTask({ description: "Implement the new feature for users" });
|
||||
const detail = await store.getTask(task.id);
|
||||
|
||||
// PROMPT.md heading should include the generated title
|
||||
expect(detail.prompt).toMatch(/^# KB-001: Implement the new feature/);
|
||||
expect(detail.prompt).toMatch(/^# KB-001: AI Generated Title/);
|
||||
});
|
||||
|
||||
it("includes generated title in PROMPT.md heading for todo tasks", async () => {
|
||||
@@ -2502,7 +2585,19 @@ describe("TaskStore", () => {
|
||||
const detail = await store.getTask(task.id);
|
||||
|
||||
// PROMPT.md heading should include the generated title
|
||||
expect(detail.prompt).toMatch(/^# KB-001: Build the authentication system/);
|
||||
expect(detail.prompt).toMatch(/^# KB-001: AI Generated Title/);
|
||||
});
|
||||
|
||||
it("handles empty title in PROMPT.md heading when AI fails", async () => {
|
||||
const engineModule = "@kb/engine";
|
||||
const { createKbAgent } = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent.mockRejectedValueOnce(new Error("AI failure"));
|
||||
|
||||
const task = await store.createTask({ description: "Some description that will fail" });
|
||||
const detail = await store.getTask(task.id);
|
||||
|
||||
// When title is empty, heading should be just the ID
|
||||
expect(detail.prompt).toMatch(/^# KB-001\n/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -379,7 +379,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
const id = await this.allocateId();
|
||||
// Generate title from description if not provided
|
||||
const title = input.title?.trim() || generateTitleFromDescription(input.description);
|
||||
const generatedTitle = await generateTitleFromDescription(input.description, this.rootDir);
|
||||
const title = input.title?.trim() || generatedTitle;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const task: Task = {
|
||||
@@ -2236,47 +2237,121 @@ ${notificationsSection}`;
|
||||
* @param description - The task description
|
||||
* @returns A generated title string, or empty string if no valid words found
|
||||
*/
|
||||
function generateTitleFromDescription(description: string): string {
|
||||
/** System prompt for AI title generation */
|
||||
const TITLE_GENERATION_PROMPT = `You are a title generation assistant for a task management system.
|
||||
|
||||
Your job is to create a concise, descriptive title from a task description.
|
||||
|
||||
## Guidelines
|
||||
- Maximum 60 characters
|
||||
- 3-8 words preferred
|
||||
- Summarize the key intent/action of the task
|
||||
- Use clear, professional language
|
||||
- Remove filler words (the, a, an) where possible
|
||||
- Output ONLY the title text, no quotes, no markdown, no explanations
|
||||
- If the input is already a good short title (3 words or less), return it as-is`;
|
||||
|
||||
// Dynamic import for @kb/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
|
||||
// Initialize the import (this runs in actual code, mocked in tests)
|
||||
async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
try {
|
||||
// Use dynamic import with variable to prevent static analysis
|
||||
const engineModule = "@kb/engine";
|
||||
const engine = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent = engine.createKbAgent;
|
||||
} catch {
|
||||
// Allow failure in test environments - agent functionality will be stubbed
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (will be awaited in actual usage)
|
||||
const engineReady = initEngine();
|
||||
|
||||
/**
|
||||
* Generate a title from description using AI summarization.
|
||||
* Returns empty string if AI fails or description is empty.
|
||||
*/
|
||||
async function generateTitleFromDescription(description: string, rootDir: string): Promise<string> {
|
||||
if (!description?.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Normalize whitespace and remove extra newlines
|
||||
const normalized = description.trim().replace(/\s+/g, " ");
|
||||
|
||||
// Extract words (sequences of alphanumeric chars, preserving internal hyphens/apostrophes)
|
||||
const words = normalized.match(/[a-zA-Z0-9]+(?:['\-_][a-zA-Z0-9]+)*/g) || [];
|
||||
|
||||
if (words.length === 0) {
|
||||
// If no alphanumeric words found, fallback to first 50 chars of normalized text
|
||||
const fallback = normalized.slice(0, 50).trim();
|
||||
return fallback || "";
|
||||
// For very short descriptions (3 words or less), use as-is without AI call
|
||||
const trimmed = description.trim();
|
||||
const wordCount = trimmed.split(/\s+/).length;
|
||||
if (wordCount <= 3 && trimmed.length <= 60) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Build title from first 8-10 words (max ~50 chars)
|
||||
const maxWords = Math.min(10, words.length);
|
||||
const minWords = Math.min(8, words.length);
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
|
||||
let title = "";
|
||||
let wordCount = 0;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
|
||||
// Check if adding this word would exceed ~50 chars
|
||||
const candidate = wordCount === 0 ? word : `${title} ${word}`;
|
||||
if (candidate.length > 50 && wordCount >= minWords) {
|
||||
break;
|
||||
}
|
||||
|
||||
title = candidate;
|
||||
wordCount++;
|
||||
|
||||
// Stop at max words
|
||||
if (wordCount >= maxWords) {
|
||||
break;
|
||||
}
|
||||
if (!createKbAgent) {
|
||||
// AI engine not available - return empty string (no fallback to truncation)
|
||||
return "";
|
||||
}
|
||||
|
||||
return title || "";
|
||||
try {
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: TITLE_GENERATION_PROMPT,
|
||||
tools: "readonly",
|
||||
});
|
||||
|
||||
if (!agentResult?.session) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Send the description to the agent
|
||||
await agentResult.session.prompt(`Generate a title for this task:\n\n${description}`);
|
||||
|
||||
// Get the response text from the agent's state
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (agentResult.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let title = "";
|
||||
if (lastMessage?.content) {
|
||||
// Handle both string and array content types
|
||||
if (typeof lastMessage.content === "string") {
|
||||
title = lastMessage.content.trim();
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
// Extract text from content blocks
|
||||
title = lastMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean the title: remove surrounding quotes, normalize whitespace
|
||||
title = title
|
||||
.replace(/^["']|["']$/g, "") // Remove surrounding quotes
|
||||
.replace(/\s+/g, " ") // Normalize whitespace
|
||||
.trim();
|
||||
|
||||
// Dispose the agent session
|
||||
try {
|
||||
agentResult.session.dispose?.();
|
||||
} catch {
|
||||
// Ignore disposal errors
|
||||
}
|
||||
|
||||
// Return empty string if AI returned nothing (no fallback)
|
||||
return title || "";
|
||||
} catch {
|
||||
// AI failed - return empty string (no fallback to truncation)
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user