FN-5850: add AI goal description drafting

Add AI-assisted goal description drafting from the Goals view add form.

- add a readonly AI drafting flow for goal descriptions with title validation and shared rate limiting
- expose a new /api/ai/draft-goal-description endpoint and dashboard API helper for requesting drafts
- add Goals view UI, styling, tests, and dashboard docs covering the draft workflow

Files changed:
 docs/dashboard-guide.md                            |   6 +
 packages/dashboard/app/api/legacy.ts               |  24 +++-
 packages/dashboard/app/components/GoalsView.css    |  14 ++
 packages/dashboard/app/components/GoalsView.tsx    |  46 +++++-
 packages/dashboard/app/components/__tests__/GoalsView.test.tsx    |  50 +++++++
 packages/dashboard/src/__tests__/ai-refine.test.ts |  75 ++++++++++
 packages/dashboard/src/__tests__/routes-agents.test.ts  |  92 ++++++++++++
 packages/dashboard/src/ai-refine.ts                | 155 ++++++++++++++++++---
 packages/dashboard/src/routes.ts                   |  58 ++++++++
 9 files changed, 489 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-5850

Fusion-Task-Lineage: $#%
!

This commit is contained in:
gsxdsm
2026-06-01 22:50:41 -07:00
parent 70e7a6b30a
commit 41c891d7d8
9 changed files with 489 additions and 31 deletions

View File

@@ -4973,6 +4973,10 @@ export interface RefineTextResponse {
refined: string;
}
export interface DraftGoalDescriptionResponse {
description: string;
}
/**
* Refine task description text using AI.
* @param text - The text to refine (1-2000 characters)
@@ -5023,11 +5027,13 @@ export function getRefineErrorMessage(error: unknown): string {
return REFINE_ERROR_MESSAGES.INVALID_TYPE;
}
// Text validation errors (400) - pass through from backend
// Validation errors (400) - pass through from backend
if (
message.startsWith("text must") ||
message.startsWith("title must") ||
message.includes("text is required") ||
message.includes("type is required")
message.includes("type is required") ||
message.includes("title is required")
) {
return error.message;
}
@@ -5036,6 +5042,20 @@ export function getRefineErrorMessage(error: unknown): string {
return REFINE_ERROR_MESSAGES.NETWORK;
}
/**
* Draft a goal description using AI from a goal title.
* @param title - The goal title to expand into a draft description
* @param projectId - Optional project ID for scoped settings resolution
* @returns The drafted goal description
* @throws Error with message for rate limit (429), validation (400), or server errors
*/
export async function draftGoalDescription(title: string, projectId?: string): Promise<string> {
const response = await api<DraftGoalDescriptionResponse>(withProjectId("/ai/draft-goal-description", projectId), {
method: "POST",
body: JSON.stringify({ title }),
});
return response.description;
}
export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), {

View File

@@ -63,6 +63,20 @@
color: var(--text-muted);
}
.goals-form-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.goals-form-draft-button {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
align-self: flex-start;
}
.goals-form textarea.input {
min-height: calc(var(--space-2xl) * 2);
resize: vertical;

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import type { Goal } from "@fusion/core";
import { Plus } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { draftGoalDescription, getRefineErrorMessage } from "../api";
import "./GoalsView.css";
export interface GoalsViewProps {
@@ -29,6 +30,7 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
const [addDescription, setAddDescription] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isDraftingDescription, setIsDraftingDescription] = useState(false);
const [editGoalId, setEditGoalId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
@@ -104,6 +106,26 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
setAddTitle("");
setAddDescription("");
setAddError(null);
setIsDraftingDescription(false);
}
async function draftAddGoalDescription() {
const title = addTitle.trim();
if (!title) {
setAddError("Title is required.");
return;
}
try {
setIsDraftingDescription(true);
setAddError(null);
const description = await draftGoalDescription(title);
setAddDescription(description);
} catch (error) {
setAddError(getRefineErrorMessage(error));
} finally {
setIsDraftingDescription(false);
}
}
async function submitAddGoal() {
@@ -269,9 +291,21 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
onChange={(event) => setAddTitle(event.target.value)}
data-testid="goals-form-title"
/>
<label className="goals-form-label" htmlFor="goals-form-description">
Description
</label>
<div className="goals-form-label-row">
<label className="goals-form-label" htmlFor="goals-form-description">
Description
</label>
<button
type="button"
className="btn goals-form-draft-button"
onClick={() => void draftAddGoalDescription()}
disabled={!addTitle.trim() || isDraftingDescription}
data-testid="goals-form-draft-ai"
>
<Sparkles aria-hidden="true" />
{isDraftingDescription ? "Drafting…" : "Draft with AI"}
</button>
</div>
<textarea
id="goals-form-description"
className="input"
@@ -286,10 +320,10 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
</p>
) : null}
<div className="goals-form-actions">
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating} data-testid="goals-form-submit">
<button type="button" className="btn btn-primary" onClick={() => void submitAddGoal()} disabled={isCreating || isDraftingDescription} data-testid="goals-form-submit">
Save
</button>
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating} data-testid="goals-form-cancel">
<button type="button" className="btn" onClick={closeAddForm} disabled={isCreating || isDraftingDescription} data-testid="goals-form-cancel">
Cancel
</button>
</div>

View File

@@ -1,12 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Goal } from "@fusion/core";
import { draftGoalDescription } from "../../api";
import { GoalsView } from "../GoalsView";
vi.mock("../../api", async () => ({
draftGoalDescription: vi.fn(),
getRefineErrorMessage: (error: unknown) => (error instanceof Error ? error.message : "Failed to refine text. Please try again."),
}));
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus" />,
Sparkles: () => <span data-testid="icon-sparkles" />,
}));
const mockDraftGoalDescription = vi.mocked(draftGoalDescription);
function makeGoal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
return {
id: overrides.id,
@@ -21,6 +30,7 @@ function makeGoal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
describe("GoalsView", () => {
beforeEach(() => {
vi.unstubAllGlobals();
mockDraftGoalDescription.mockReset();
});
afterEach(() => {
@@ -139,6 +149,46 @@ describe("GoalsView", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("Title is required.");
});
it("keeps the draft button disabled until a title is provided", () => {
render(<GoalsView initialGoals={[]} />);
fireEvent.click(screen.getByTestId("goals-add-button"));
const draftButton = screen.getByTestId("goals-form-draft-ai");
expect(draftButton).toBeDisabled();
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
expect(screen.getByTestId("goals-form-draft-ai")).toBeEnabled();
});
it("drafts a description from the goal title", async () => {
mockDraftGoalDescription.mockResolvedValueOnce("Expand the extension ecosystem with better support and adoption goals.");
render(<GoalsView initialGoals={[]} />);
fireEvent.click(screen.getByTestId("goals-add-button"));
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
fireEvent.click(screen.getByTestId("goals-form-draft-ai"));
await waitFor(() => expect(mockDraftGoalDescription).toHaveBeenCalledWith("Grow ecosystem"));
expect(screen.getByTestId("goals-form-description")).toHaveValue(
"Expand the extension ecosystem with better support and adoption goals."
);
});
it("shows an error when AI drafting fails", async () => {
mockDraftGoalDescription.mockRejectedValueOnce(new Error("Too many refinement requests. Please wait an hour."));
render(<GoalsView initialGoals={[]} />);
fireEvent.click(screen.getByTestId("goals-add-button"));
fireEvent.change(screen.getByTestId("goals-form-title"), { target: { value: "Grow ecosystem" } });
fireEvent.click(screen.getByTestId("goals-form-draft-ai"));
expect(await screen.findByRole("alert")).toHaveTextContent("Too many refinement requests. Please wait an hour.");
});
it("creates goal via API and closes form", async () => {
const created = makeGoal({ id: "g3", title: "Created Goal", description: "new description" });
const fetchMock = vi.fn().mockResolvedValue({

View File

@@ -1,7 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
refineText,
draftGoalDescription,
validateRefineRequest,
validateGoalDraftRequest,
checkRateLimit,
getRateLimitResetTime,
__resetRefineState,
@@ -11,6 +13,7 @@ import {
VALID_REFINEMENT_TYPES,
MIN_TEXT_LENGTH,
MAX_TEXT_LENGTH,
MAX_GOAL_TITLE_LENGTH,
MAX_REQUESTS_PER_HOUR,
RATE_LIMIT_WINDOW_MS,
} from "../ai-refine.js";
@@ -132,6 +135,34 @@ describe("ai-refine module", () => {
});
});
describe("validateGoalDraftRequest", () => {
it("accepts a valid goal title and trims whitespace", () => {
expect(validateGoalDraftRequest(" Improve plugin ecosystem ")).toBe("Improve plugin ecosystem");
});
it("throws ValidationError for missing title", () => {
expect(() => validateGoalDraftRequest(undefined)).toThrow(ValidationError);
expect(() => validateGoalDraftRequest(undefined)).toThrow("title is required");
});
it("throws ValidationError for non-string title", () => {
expect(() => validateGoalDraftRequest(123)).toThrow(ValidationError);
expect(() => validateGoalDraftRequest(123)).toThrow("title must be a string");
});
it("throws ValidationError for empty trimmed title", () => {
expect(() => validateGoalDraftRequest(" ")).toThrow(ValidationError);
expect(() => validateGoalDraftRequest(" ")).toThrow("title is required");
});
it("throws ValidationError for titles exceeding the max length", () => {
expect(() => validateGoalDraftRequest("a".repeat(MAX_GOAL_TITLE_LENGTH + 1))).toThrow(ValidationError);
expect(() => validateGoalDraftRequest("a".repeat(MAX_GOAL_TITLE_LENGTH + 1))).toThrow(
`title must not exceed ${MAX_GOAL_TITLE_LENGTH} characters`
);
});
});
describe("checkRateLimit", () => {
it("allows first request from an IP", () => {
expect(checkRateLimit("192.168.1.1")).toBe(true);
@@ -276,6 +307,50 @@ describe("ai-refine module", () => {
});
});
describe("draftGoalDescription", () => {
function createDraftMockAgent(content: string | Array<{ type: string; text: string }>) {
return {
session: {
state: {
messages: [{ role: "assistant", content }],
},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
};
}
it("drafts a goal description from the last assistant message", async () => {
const mockAgent = createDraftMockAgent([
{ type: "text", text: "Grow the integration ecosystem with clearer extension pathways." },
{ type: "text", text: " Success means partners can discover and ship supported integrations faster." },
]);
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).resolves.toBe(
"Grow the integration ecosystem with clearer extension pathways. Success means partners can discover and ship supported integrations faster."
);
expect(mockCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/tmp/project",
tools: "readonly",
})
);
expect(mockAgent.session.prompt).toHaveBeenCalledWith("Goal title: Grow plugin ecosystem");
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
});
it("throws AiServiceError when AI returns an empty response", async () => {
const mockAgent = createDraftMockAgent(" ");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await expect(draftGoalDescription("Grow plugin ecosystem", "/tmp/project")).rejects.toThrow(
"AI returned empty response"
);
expect(mockAgent.session.dispose).toHaveBeenCalledTimes(1);
});
});
describe("__resetRefineState", () => {
it("clears all rate limit entries", () => {
const ip = "192.168.1.1";

View File

@@ -38,6 +38,7 @@ import { get as performGet, request as performRequest } from "../test-request.js
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js";
import * as aiRefineModule from "../ai-refine.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
// Mock @fusion/core for gh CLI auth checks
@@ -3223,6 +3224,97 @@ describe("POST /api/ai/refine-text with projectId scoping", () => {
});
});
describe("POST /api/ai/draft-goal-description", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/test/project"),
});
aiRefineModule.__resetRefineState();
});
afterEach(() => {
aiRefineModule.__resetRefineState();
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns a drafted description for a valid title", async () => {
const draftSpy = vi
.spyOn(aiRefineModule, "draftGoalDescription")
.mockResolvedValueOnce("Grow the ecosystem with clear extension support and measurable adoption.");
const res = await REQUEST(
buildApp(),
"POST",
"/api/ai/draft-goal-description",
JSON.stringify({ title: "Grow plugin ecosystem" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
description: "Grow the ecosystem with clear extension support and measurable adoption.",
});
expect(draftSpy).toHaveBeenCalledWith("Grow plugin ecosystem", "/test/project", undefined);
});
it("returns 400 when title is missing or empty", async () => {
const missing = await REQUEST(
buildApp(),
"POST",
"/api/ai/draft-goal-description",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(missing.status).toBe(400);
expect(missing.body.error).toContain("title is required");
const empty = await REQUEST(
buildApp(),
"POST",
"/api/ai/draft-goal-description",
JSON.stringify({ title: " " }),
{ "Content-Type": "application/json" },
);
expect(empty.status).toBe(400);
expect(empty.body.error).toContain("title is required");
});
it("returns 429 when draft requests are rate limited", async () => {
const app = buildApp();
for (let i = 0; i < 10; i++) {
const res = await REQUEST(
app,
"POST",
"/api/ai/draft-goal-description",
JSON.stringify({ title: `Goal ${i}` }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
}
const rateLimited = await REQUEST(
app,
"POST",
"/api/ai/draft-goal-description",
JSON.stringify({ title: "Goal 11" }),
{ "Content-Type": "application/json" },
);
expect(rateLimited.status).toBe(429);
expect(rateLimited.body.error).toContain("Rate limit exceeded");
});
});
describe("Messaging Routes", () => {
let rootDir: string;
let store: TaskStore;

View File

@@ -86,12 +86,28 @@ Your job is to refine task descriptions based on the user's selected refinement
- Output ONLY the refined text, no markdown formatting, no explanations
- The output should be a direct replacement for the input text`;
/** System prompt for drafting goal descriptions */
export const GOAL_DRAFT_SYSTEM_PROMPT = `You are a strategic planning assistant for a goal tracking system.
Given a goal title, draft a concise goal description that helps a team understand the intent, scope, and success signal of the goal.
Guidelines:
- Write plain text only
- No markdown headings, bullets, or preamble
- Use a professional, actionable tone
- Return 1 to 3 short paragraphs
- Capture the goal's purpose, likely scope, and how success could be recognized
- Do not invent unrelated product names, timelines, metrics, or implementation specifics that are not implied by the title`;
/** Maximum text length in characters */
export const MAX_TEXT_LENGTH = 2000;
/** Minimum text length in characters */
export const MIN_TEXT_LENGTH = 1;
/** Maximum goal title length in characters */
export const MAX_GOAL_TITLE_LENGTH = 200;
/** Rate limit: max requests per IP per hour */
export const MAX_REQUESTS_PER_HOUR = 10;
@@ -235,6 +251,61 @@ export function validateRefineRequest(
return { text, type: type as RefinementType };
}
/**
* Validate goal-description drafting request.
* Throws ValidationError for invalid input.
*/
export function validateGoalDraftRequest(title: unknown): string {
if (title === undefined || title === null) {
throw new ValidationError("title is required");
}
if (typeof title !== "string") {
throw new ValidationError("title must be a string");
}
const trimmedTitle = title.trim();
if (!trimmedTitle) {
throw new ValidationError("title is required");
}
if (trimmedTitle.length > MAX_GOAL_TITLE_LENGTH) {
throw new ValidationError(`title must not exceed ${MAX_GOAL_TITLE_LENGTH} characters`);
}
return trimmedTitle;
}
function extractLastAssistantText(messages: unknown): string {
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (Array.isArray(messages) ? messages : [])
.filter((message): message is AgentMessage => Boolean(message) && typeof message === "object" && "role" in message)
.filter((message) => message.role === "assistant")
.pop();
if (!lastMessage?.content) {
return "";
}
if (typeof lastMessage.content === "string") {
return lastMessage.content.trim();
}
if (Array.isArray(lastMessage.content)) {
return lastMessage.content
.filter((content): content is { type: "text"; text: string } => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
}
return "";
}
// ── AI Integration ───────────────────────────────────────────────────────────
/**
@@ -277,29 +348,7 @@ export async function refineText(
// Send message to agent and get response
await agentResult.session.prompt(prompt);
// 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 refinedText = "";
if (lastMessage?.content) {
// Handle both string and array content types
if (typeof lastMessage.content === "string") {
refinedText = lastMessage.content.trim();
} else if (Array.isArray(lastMessage.content)) {
// Extract text from content blocks
refinedText = 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();
}
}
const refinedText = extractLastAssistantText(agentResult.session.state.messages);
if (!refinedText) {
throw new AiServiceError("AI returned empty response");
@@ -330,6 +379,66 @@ export async function refineText(
}
}
/**
* Draft a goal description using a readonly AI agent.
* @param title - Goal title to expand into a description
* @param rootDir - Project root directory for AI agent context
* @param promptOverrides - Optional prompt overrides (unused for this inline prompt)
* @returns Drafted goal description text
*/
export async function draftGoalDescription(
title: string,
rootDir: string,
_promptOverrides?: PromptOverrideMap,
): Promise<string> {
await ensureEngineReady();
if (!createFnAgent) {
throw new AiServiceError("AI engine not available");
}
const agentResult = await createFnAgent({
cwd: rootDir,
systemPrompt: GOAL_DRAFT_SYSTEM_PROMPT,
tools: "readonly",
});
if (!agentResult?.session) {
throw new AiServiceError("Failed to initialize AI agent");
}
const prompt = `Goal title: ${title}`;
try {
await agentResult.session.prompt(prompt);
const description = extractLastAssistantText(agentResult.session.state.messages);
if (!description) {
throw new AiServiceError("AI returned empty response");
}
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
return description;
} catch (err) {
try {
agentResult.session.dispose?.();
} catch {
// Ignore disposal errors
}
if (err instanceof AiServiceError) {
throw err;
}
throw new AiServiceError(err instanceof Error ? err.message : "AI processing failed");
}
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {

View File

@@ -1784,6 +1784,64 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/ai/draft-goal-description
* AI-powered goal description drafting from a goal title.
* Body: { title: string }
* Returns: { description: string }
*
* Rate limited: 10 requests per hour per IP
*/
router.post("/ai/draft-goal-description", async (req, res) => {
try {
const { title } = req.body;
const ip = req.ip || req.socket.remoteAddress || "unknown";
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const settings = await scopedStore.getSettings();
const {
validateGoalDraftRequest,
checkRateLimit,
getRateLimitResetTime,
draftGoalDescription,
RateLimitError: _RateLimitError4,
ValidationError,
AiServiceError: _AiServiceError2,
} = await import("./ai-refine.js");
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
throw rateLimited(`Rate limit exceeded. Maximum 10 draft requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`);
}
let validatedTitle: string;
try {
validatedTitle = validateGoalDraftRequest(title);
} catch (err) {
if (err instanceof ValidationError) {
throw badRequest(err instanceof Error ? err.message : String(err));
}
throw err;
}
const description = await draftGoalDescription(validatedTitle, rootDir, settings.promptOverrides);
res.json({ description });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error && err.name === "RateLimitError") {
throw rateLimited(err.message);
} else if (err instanceof Error && err.name === "AiServiceError") {
rethrowAsApiError(err, "AI service error");
} else {
rethrowAsApiError(err, "Failed to draft goal description");
}
}
});
/**
* POST /api/ai/summarize-title
* AI-powered title generation from task descriptions.