feat(FN-865): add AI agent generation service with dashboard UI

- Add agent generation service with OpenAI/Anthropic support, streaming, and error handling
- Add POST /agent/generate API endpoint with SSE streaming and cost tracking
- Add API client functions for agent generation with AbortController support
- Create AgentGenerationModal component with live preview, streaming, and import flow
- Integrate AI Generate button into NewAgentDialog with configuration options
- Add comprehensive unit tests for service and component
- Add changeset for @gsxdsm/fusion minor bump
This commit is contained in:
gsxdsm
2026-04-04 22:53:10 -07:00
parent e5391a05a5
commit 602534a1b7
8 changed files with 1781 additions and 4 deletions

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
startAgentGeneration,
generateAgentSpec,
getAgentGenerationSession,
cleanupAgentGenerationSession,
checkRateLimit,
getRateLimitResetTime,
parseGenerationResponse,
__resetAgentGenerationState,
RateLimitError,
SessionNotFoundError,
} from "./agent-generation.js";
// Counter for unique IPs per test
let ipCounter = 0;
function getUniqueIp(): string {
return `127.0.0.${++ipCounter}`;
}
describe("agent-generation module", () => {
beforeEach(() => {
vi.useFakeTimers();
__resetAgentGenerationState();
});
afterEach(() => {
vi.useRealTimers();
});
describe("startAgentGeneration", () => {
it("creates a session with valid role description", async () => {
const mockIp = getUniqueIp();
const session = await startAgentGeneration(mockIp, "Senior frontend code reviewer");
expect(session.id).toBeDefined();
expect(typeof session.id).toBe("string");
expect(session.roleDescription).toBe("Senior frontend code reviewer");
expect(session.spec).toBeUndefined();
expect(session.createdAt).toBeInstanceOf(Date);
expect(session.updatedAt).toBeInstanceOf(Date);
});
it("does not expose IP in the public session", async () => {
const mockIp = getUniqueIp();
const session = await startAgentGeneration(mockIp, "Test role");
expect((session as Record<string, unknown>).ip).toBeUndefined();
});
it("enforces rate limiting", async () => {
const mockIp = getUniqueIp();
// Create max sessions (10 per hour)
for (let i = 0; i < 10; i++) {
await startAgentGeneration(mockIp, `Role ${i}`);
}
// 11th session should fail
await expect(startAgentGeneration(mockIp, "One more")).rejects.toThrow(RateLimitError);
});
it("allows new sessions after rate limit window expires", async () => {
const mockIp = getUniqueIp();
for (let i = 0; i < 10; i++) {
await startAgentGeneration(mockIp, `Role ${i}`);
}
// Advance time by 1 hour + 1 minute
vi.advanceTimersByTime(61 * 60 * 1000);
const session = await startAgentGeneration(mockIp, "New role after reset");
expect(session.id).toBeDefined();
});
it("generates different session IDs for each session", async () => {
const ip1 = getUniqueIp();
const ip2 = getUniqueIp();
const session1 = await startAgentGeneration(ip1, "Role 1");
const session2 = await startAgentGeneration(ip2, "Role 2");
expect(session1.id).not.toBe(session2.id);
});
it("rate limits independently per IP", async () => {
const ip1 = getUniqueIp();
const ip2 = getUniqueIp();
for (let i = 0; i < 10; i++) {
await startAgentGeneration(ip1, `Role ${i}`);
}
// ip2 should still work
const session = await startAgentGeneration(ip2, "Role from another IP");
expect(session.id).toBeDefined();
});
});
describe("generateAgentSpec", () => {
it("throws SessionNotFoundError for non-existent session", async () => {
await expect(
generateAgentSpec("non-existent-session-id", "/tmp")
).rejects.toThrow(SessionNotFoundError);
});
});
describe("getAgentGenerationSession", () => {
it("returns session after creation", async () => {
const mockIp = getUniqueIp();
const created = await startAgentGeneration(mockIp, "Test role");
const retrieved = getAgentGenerationSession(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
expect(retrieved!.roleDescription).toBe("Test role");
});
it("returns undefined for non-existent session", () => {
const result = getAgentGenerationSession("non-existent");
expect(result).toBeUndefined();
});
it("returns undefined after cleanup", async () => {
const mockIp = getUniqueIp();
const created = await startAgentGeneration(mockIp, "Test role");
cleanupAgentGenerationSession(created.id);
const result = getAgentGenerationSession(created.id);
expect(result).toBeUndefined();
});
});
describe("cleanupAgentGenerationSession", () => {
it("removes session", async () => {
const mockIp = getUniqueIp();
const created = await startAgentGeneration(mockIp, "Test role");
cleanupAgentGenerationSession(created.id);
expect(getAgentGenerationSession(created.id)).toBeUndefined();
});
it("is idempotent for non-existent session", () => {
expect(() => cleanupAgentGenerationSession("non-existent")).not.toThrow();
});
});
describe("checkRateLimit", () => {
beforeEach(() => {
__resetAgentGenerationState();
});
it("allows first request from new IP", () => {
expect(checkRateLimit("1.2.3.4")).toBe(true);
});
it("blocks after exceeding limit", () => {
for (let i = 0; i < 10; i++) {
checkRateLimit("1.2.3.4");
}
expect(checkRateLimit("1.2.3.4")).toBe(false);
});
it("allows requests after window expires", () => {
for (let i = 0; i < 10; i++) {
checkRateLimit("1.2.3.4");
}
vi.advanceTimersByTime(61 * 60 * 1000);
expect(checkRateLimit("1.2.3.4")).toBe(true);
});
});
describe("getRateLimitResetTime", () => {
it("returns null for unknown IP", () => {
expect(getRateLimitResetTime("unknown")).toBeNull();
});
it("returns a date after first request", () => {
checkRateLimit("1.2.3.4");
const resetTime = getRateLimitResetTime("1.2.3.4");
expect(resetTime).toBeInstanceOf(Date);
expect(resetTime!.getTime()).toBeGreaterThan(Date.now());
});
});
describe("parseGenerationResponse", () => {
it("parses valid JSON response", () => {
const json = JSON.stringify({
title: "Senior Frontend Reviewer",
icon: "🔍",
role: "reviewer",
description: "Reviews frontend code for quality",
systemPrompt: "# Role\nYou are a senior frontend reviewer.",
thinkingLevel: "medium",
maxTurns: 25,
});
const spec = parseGenerationResponse(json);
expect(spec.title).toBe("Senior Frontend Reviewer");
expect(spec.icon).toBe("🔍");
expect(spec.role).toBe("reviewer");
expect(spec.description).toBe("Reviews frontend code for quality");
expect(spec.systemPrompt).toBe("# Role\nYou are a senior frontend reviewer.");
expect(spec.thinkingLevel).toBe("medium");
expect(spec.maxTurns).toBe(25);
});
it("parses JSON wrapped in markdown code block", () => {
const inner = JSON.stringify({
title: "Test Agent",
icon: "🤖",
role: "custom",
description: "A test agent",
systemPrompt: "Test prompt",
thinkingLevel: "off",
maxTurns: 10,
});
const wrapped = "```json\n" + inner + "\n```";
const spec = parseGenerationResponse(wrapped);
expect(spec.title).toBe("Test Agent");
});
it("parses JSON with surrounding text", () => {
const json = JSON.stringify({
title: "Test Agent",
icon: "🤖",
role: "custom",
description: "A test agent",
systemPrompt: "Test prompt",
thinkingLevel: "off",
maxTurns: 10,
});
const text = `Here is the specification:\n${json}\nHope this helps!`;
const spec = parseGenerationResponse(text);
expect(spec.title).toBe("Test Agent");
});
it("applies defaults for missing fields", () => {
const json = JSON.stringify({});
const spec = parseGenerationResponse(json);
expect(spec.title).toBe("Custom Agent");
expect(spec.icon).toBe("🤖");
expect(spec.role).toBe("custom");
expect(spec.description).toBe("");
expect(spec.systemPrompt).toBe("");
expect(spec.thinkingLevel).toBe("off");
expect(spec.maxTurns).toBe(10);
});
it("truncates title to 60 characters", () => {
const longTitle = "A".repeat(100);
const json = JSON.stringify({
title: longTitle,
icon: "🤖",
role: "custom",
description: "test",
systemPrompt: "test",
thinkingLevel: "off",
maxTurns: 10,
});
const spec = parseGenerationResponse(json);
expect(spec.title.length).toBe(60);
});
it("clamps maxTurns to valid range", () => {
const json = JSON.stringify({
title: "Test",
maxTurns: 999,
});
const spec = parseGenerationResponse(json);
expect(spec.maxTurns).toBe(500);
const json2 = JSON.stringify({ title: "Test", maxTurns: -5 });
const spec2 = parseGenerationResponse(json2);
expect(spec2.maxTurns).toBe(1); // clamped to minimum
});
it("defaults invalid thinkingLevel to off", () => {
const json = JSON.stringify({
title: "Test",
thinkingLevel: "ultra",
});
const spec = parseGenerationResponse(json);
expect(spec.thinkingLevel).toBe("off");
});
it("repairs JSON with trailing commas", () => {
const broken = '{"title":"Test","icon":"X","role":"custom","description":"d","systemPrompt":"s","thinkingLevel":"off","maxTurns":10,}';
const spec = parseGenerationResponse(broken);
expect(spec.title).toBe("Test");
});
it("throws for non-JSON text", () => {
expect(() => parseGenerationResponse("Hello world, this is not JSON")).toThrow(
"AI returned no valid JSON"
);
});
it("throws for empty text", () => {
expect(() => parseGenerationResponse("")).toThrow("AI returned no valid JSON");
});
});
describe("TTL cleanup", () => {
it("sessions are retrievable within TTL", async () => {
const mockIp = getUniqueIp();
const session = await startAgentGeneration(mockIp, "Test role");
// Session should exist within TTL
expect(getAgentGenerationSession(session.id)).toBeDefined();
// Advance to just before TTL
vi.advanceTimersByTime(29 * 60 * 1000);
expect(getAgentGenerationSession(session.id)).toBeDefined();
});
it("session data is accessible after creation", async () => {
const mockIp = getUniqueIp();
const session = await startAgentGeneration(mockIp, "Security auditor role");
const retrieved = getAgentGenerationSession(session.id);
expect(retrieved).toBeDefined();
expect(retrieved!.roleDescription).toBe("Security auditor role");
});
});
});