feat(FN-1054): replace planning stubs with AI-powered agent sessions
- Remove hardcoded/planned stub responses from planning createSession and related functions - Wire AI agent into planning session for interactive Q&A-based task planning - Update JSDoc to remove stub references and reflect actual implementation - Add comprehensive tests for AI-powered planning (planning.test.ts, routes.test.ts) - Add mock agent setup to routes test for planning endpoint coverage
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
checkRateLimit,
|
||||
getRateLimitResetTime,
|
||||
__resetPlanningState,
|
||||
__setCreateKbAgent,
|
||||
RateLimitError,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
@@ -18,28 +19,122 @@ import {
|
||||
} from "./planning.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
|
||||
// ── Mock Agent Factory ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a mock AI agent that responds with predefined JSON responses.
|
||||
* Each call to `prompt()` consumes the next response in the array.
|
||||
*/
|
||||
function createMockAgent(responses: string[]) {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
let callIndex = 0;
|
||||
|
||||
return {
|
||||
session: {
|
||||
state: { messages },
|
||||
prompt: vi.fn(async (msg: string) => {
|
||||
messages.push({ role: "user", content: msg });
|
||||
const response = responses[callIndex++] ?? responses[responses.length - 1];
|
||||
messages.push({ role: "assistant", content: response });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Standard AI responses for a 3-question flow */
|
||||
const STANDARD_QUESTION_RESPONSES = [
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: "What is the scope of this plan?",
|
||||
description: "This helps estimate the size and complexity of the task.",
|
||||
options: [
|
||||
{ id: "small", label: "Small", description: "Quick" },
|
||||
{ id: "medium", label: "Medium", description: "Standard" },
|
||||
{ id: "large", label: "Large", description: "Complex" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-requirements",
|
||||
type: "text",
|
||||
question: "What are the key requirements?",
|
||||
description: "List acceptance criteria.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there specific technologies to use?",
|
||||
description: "Answer yes if you have preferences.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Build Auth System",
|
||||
description: "Build a user authentication system\n\nRequirements: Standard implementation\n\nGenerated via Planning Mode",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
/** Root dir for all test sessions */
|
||||
const TEST_ROOT_DIR = "/test/project";
|
||||
|
||||
// Counter for unique IPs per test
|
||||
let ipCounter = 0;
|
||||
function getUniqueIp(): string {
|
||||
return `127.0.0.${++ipCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: set up a fresh mock agent for the next createSession call.
|
||||
* Returns the agent so tests can inspect `.session.prompt` calls.
|
||||
*/
|
||||
function setupMockAgent(responses?: string[]) {
|
||||
const agent = createMockAgent(responses ?? STANDARD_QUESTION_RESPONSES);
|
||||
__setCreateKbAgent(async () => agent);
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe("planning module", () => {
|
||||
const initialPlan = "Build a user authentication system";
|
||||
|
||||
// Ensure the engine is loaded before any tests run.
|
||||
// The module-level `engineReady` promise may still be resolving
|
||||
// (importing @fusion/engine) when the first test starts.
|
||||
// We set the mock BEFORE awaiting, so initEngine skips the real import
|
||||
// on subsequent calls (though the first call may already be in-flight).
|
||||
beforeAll(async () => {
|
||||
// Wait for the initial engine load to complete (could be real or failed)
|
||||
// by importing the module and waiting for its side effects.
|
||||
// Then set our mock which will take effect for all test calls.
|
||||
setupMockAgent();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetPlanningState();
|
||||
setupMockAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__setCreateKbAgent(undefined as any);
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("creates a session with valid initial plan", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const result = await createSession(mockIp, initialPlan);
|
||||
const result = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(typeof result.sessionId).toBe("string");
|
||||
@@ -48,45 +143,107 @@ describe("planning module", () => {
|
||||
expect(result.firstQuestion.type).toBe("single_select");
|
||||
});
|
||||
|
||||
it("throws if rootDir is not provided", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
await expect(createSession(mockIp, initialPlan)).rejects.toThrow("rootDir is required");
|
||||
});
|
||||
|
||||
it("enforces rate limiting", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions (5 per hour)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`);
|
||||
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
||||
}
|
||||
|
||||
// 6th session should fail
|
||||
await expect(createSession(mockIp, initialPlan)).rejects.toThrow(RateLimitError);
|
||||
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(RateLimitError);
|
||||
});
|
||||
|
||||
it("allows new sessions after rate limit window expires", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`);
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
||||
}
|
||||
|
||||
// Advance time by 1 hour + 1 minute
|
||||
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
// Should now be able to create a new session
|
||||
const result = await createSession(mockIp, "New plan after reset", undefined, TEST_ROOT_DIR);
|
||||
expect(result.sessionId).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
// Advance time by 1 hour + 1 minute
|
||||
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
// Should now be able to create a new session
|
||||
const result = await createSession(mockIp, "New plan after reset");
|
||||
expect(result.sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
it("generates different session IDs for each session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const result1 = await createSession(mockIp, "Plan 1");
|
||||
const result2 = await createSession(mockIp, "Plan 2");
|
||||
const result1 = await createSession(mockIp, "Plan 1", undefined, TEST_ROOT_DIR);
|
||||
const result2 = await createSession(mockIp, "Plan 2", undefined, TEST_ROOT_DIR);
|
||||
|
||||
expect(result1.sessionId).not.toBe(result2.sessionId);
|
||||
});
|
||||
|
||||
it("stores the AI agent on the session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.agent).toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up session on agent failure", async () => {
|
||||
__setCreateKbAgent(async () => {
|
||||
throw new Error("Agent creation failed");
|
||||
});
|
||||
|
||||
const mockIp = getUniqueIp();
|
||||
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(
|
||||
"Agent creation failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up session when AI returns unparseable output", async () => {
|
||||
setupMockAgent(["I am not JSON at all"]);
|
||||
|
||||
const mockIp = getUniqueIp();
|
||||
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(
|
||||
"Failed to get first question from AI"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles AI returning a summary instead of a first question", async () => {
|
||||
setupMockAgent([
|
||||
JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Auth System",
|
||||
description: "Build auth",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Login"],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const mockIp = getUniqueIp();
|
||||
const result = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Should return a confirm question wrapping the summary
|
||||
expect(result.firstQuestion.type).toBe("confirm");
|
||||
expect(result.firstQuestion.id).toBe("q-direct-summary");
|
||||
expect(result.firstQuestion.question).toContain("Auth System");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitResponse", () => {
|
||||
it("processes response and returns next question", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
const response = await submitResponse(sessionId, { scope: "medium" });
|
||||
|
||||
@@ -98,7 +255,7 @@ describe("planning module", () => {
|
||||
|
||||
it("returns summary after multiple responses", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Submit first response
|
||||
const response1 = await submitResponse(sessionId, { scope: "medium" });
|
||||
@@ -126,7 +283,7 @@ describe("planning module", () => {
|
||||
|
||||
it("throws InvalidSessionStateError when no active question", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
@@ -136,12 +293,34 @@ describe("planning module", () => {
|
||||
// Try to submit another response
|
||||
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError if session has no AI agent", async () => {
|
||||
// Create a session without an agent by directly manipulating state
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Manually remove the agent to simulate a corrupted session
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
if (session) {
|
||||
session.agent = undefined;
|
||||
}
|
||||
|
||||
await expect(submitResponse(sessionId, { answer: "test" })).rejects.toThrow(
|
||||
InvalidSessionStateError
|
||||
);
|
||||
try {
|
||||
await submitResponse(sessionId, { answer: "test" });
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toBe("Planning session has no AI agent");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelSession", () => {
|
||||
it("removes an active session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
await cancelSession(sessionId);
|
||||
|
||||
@@ -157,7 +336,7 @@ describe("planning module", () => {
|
||||
describe("getSession", () => {
|
||||
it("returns session for valid ID", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
const session = getSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
@@ -174,7 +353,7 @@ describe("planning module", () => {
|
||||
describe("getCurrentQuestion", () => {
|
||||
it("returns current question for active session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId, firstQuestion } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId, firstQuestion } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
const question = getCurrentQuestion(sessionId);
|
||||
expect(question).toEqual(firstQuestion);
|
||||
@@ -182,7 +361,7 @@ describe("planning module", () => {
|
||||
|
||||
it("returns undefined for completed session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
@@ -197,7 +376,7 @@ describe("planning module", () => {
|
||||
describe("getSummary", () => {
|
||||
it("returns summary for completed session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
@@ -212,7 +391,7 @@ describe("planning module", () => {
|
||||
|
||||
it("returns undefined for incomplete session", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
const summary = getSummary(sessionId);
|
||||
expect(summary).toBeUndefined();
|
||||
@@ -222,7 +401,7 @@ describe("planning module", () => {
|
||||
describe("cleanupSession", () => {
|
||||
it("removes a session from memory", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
cleanupSession(sessionId);
|
||||
|
||||
@@ -246,7 +425,7 @@ describe("planning module", () => {
|
||||
|
||||
// Max out the rate limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(mockIp, `Plan ${i}`);
|
||||
await createSession(mockIp, `Plan ${i}`, undefined, TEST_ROOT_DIR);
|
||||
}
|
||||
|
||||
const resetTime = getRateLimitResetTime(mockIp);
|
||||
@@ -257,20 +436,25 @@ describe("planning module", () => {
|
||||
|
||||
describe("session TTL", () => {
|
||||
it("sessions expire after TTL", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan);
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Verify session exists
|
||||
expect(getSession(sessionId)).toBeDefined();
|
||||
// Verify session exists
|
||||
expect(getSession(sessionId)).toBeDefined();
|
||||
|
||||
// Advance time by 31 minutes
|
||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||
// Advance time by 31 minutes
|
||||
vi.advanceTimersByTime(31 * 60 * 1000);
|
||||
|
||||
// Trigger cleanup by creating a new session
|
||||
await createSession(getUniqueIp(), "Another plan");
|
||||
// Trigger cleanup by creating a new session
|
||||
await createSession(getUniqueIp(), "Another plan", undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Note: Session should be expired after cleanup runs
|
||||
// We can't directly verify as cleanup is async
|
||||
// Note: Session should be expired after cleanup runs
|
||||
// We can't directly verify as cleanup is async
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,10 +562,9 @@ describe("planning module", () => {
|
||||
/** Helper: create a session and complete it to get a summary */
|
||||
async function createCompletedSession(
|
||||
ip: string,
|
||||
plan: string,
|
||||
overrides?: Partial<PlanningSummary>
|
||||
plan: string
|
||||
): Promise<string> {
|
||||
const { sessionId } = await createSession(ip, plan);
|
||||
const { sessionId } = await createSession(ip, plan, undefined, TEST_ROOT_DIR);
|
||||
// Complete the session by submitting 3 responses
|
||||
await submitResponse(sessionId, { scope: "medium" });
|
||||
await submitResponse(sessionId, { requirements: "Test requirements" });
|
||||
@@ -396,7 +579,7 @@ describe("planning module", () => {
|
||||
|
||||
it("returns empty array if session has no summary (not complete)", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Incomplete session");
|
||||
const { sessionId } = await createSession(mockIp, "Incomplete session", undefined, TEST_ROOT_DIR);
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
expect(result).toEqual([]);
|
||||
@@ -408,7 +591,7 @@ describe("planning module", () => {
|
||||
|
||||
const result = generateSubtasksFromPlanning(sessionId);
|
||||
|
||||
// The stubbed session generates 3 key deliverables:
|
||||
// The AI-generated session produces 3 key deliverables:
|
||||
// "Implementation", "Tests", "Documentation"
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
@@ -442,7 +625,7 @@ describe("planning module", () => {
|
||||
|
||||
it("generates fallback subtasks when keyDeliverables is empty", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Fallback test");
|
||||
const { sessionId } = await createSession(mockIp, "Fallback test", undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Complete the session normally, then manually clear keyDeliverables
|
||||
await submitResponse(sessionId, { scope: "small" });
|
||||
@@ -484,7 +667,7 @@ describe("planning module", () => {
|
||||
|
||||
it("assigns correct sizes based on deliverable position", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
const { sessionId } = await createSession(mockIp, "Multi-deliverable test");
|
||||
const { sessionId } = await createSession(mockIp, "Multi-deliverable test", undefined, TEST_ROOT_DIR);
|
||||
|
||||
// Complete the session
|
||||
await submitResponse(sessionId, { scope: "large" });
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
* Sessions are stored in-memory with TTL cleanup.
|
||||
*
|
||||
* Features:
|
||||
* - AI agent integration with real-time streaming via SSE
|
||||
* - AI agent integration via createKbAgent for real-time planning conversations
|
||||
* - Streaming via SSE (createSessionWithAgent) and non-streaming (createSession)
|
||||
* - Rate limiting per IP
|
||||
* - Session expiration and cleanup
|
||||
*
|
||||
* NOTE: AI Agent integration uses createKbAgent from "@fusion/engine" for
|
||||
* real-time planning conversations with thinking output streaming.
|
||||
* - JSON response parsing with robust extraction and repair
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -353,217 +352,6 @@ export function getRateLimitResetTime(ip: string): Date | null {
|
||||
return new Date(entry.firstRequestAt.getTime() + RATE_LIMIT_WINDOW_MS);
|
||||
}
|
||||
|
||||
// ── Planning Session Class ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PlanningSession class for managing AI-guided planning conversations.
|
||||
*
|
||||
* This class encapsulates the planning session state and provides methods
|
||||
* for interacting with the AI agent to generate questions and summaries.
|
||||
*/
|
||||
export class PlanningSession {
|
||||
id: string;
|
||||
ip: string;
|
||||
initialPlan: string;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
agent?: any;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(initialPlan: string, ip: string) {
|
||||
this.id = randomUUID();
|
||||
this.ip = ip;
|
||||
this.initialPlan = initialPlan;
|
||||
this.history = [];
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next question from the AI agent based on the initial plan.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
async getNextQuestion(): Promise<PlanningQuestion | PlanningSummary> {
|
||||
if (this.history.length === 0) {
|
||||
return generateFirstQuestion(this.initialPlan);
|
||||
}
|
||||
return this.generateNextQuestionOrSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a response and get the next question or summary.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
async submitResponse(response: unknown): Promise<PlanningQuestion | PlanningSummary> {
|
||||
if (!this.currentQuestion) {
|
||||
throw new InvalidSessionStateError("No active question in session");
|
||||
}
|
||||
|
||||
this.history.push({
|
||||
question: this.currentQuestion,
|
||||
response,
|
||||
});
|
||||
this.updatedAt = new Date();
|
||||
|
||||
return this.generateNextQuestionOrSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the session and cleanup resources.
|
||||
*/
|
||||
dispose(): void {
|
||||
// Cleanup any resources if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next question or summary based on session history.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
private generateNextQuestionOrSummary(): PlanningQuestion | PlanningSummary {
|
||||
const historyLength = this.history.length;
|
||||
|
||||
if (historyLength < 2) {
|
||||
return {
|
||||
id: `q-${historyLength + 1}`,
|
||||
type: "text",
|
||||
question: "What are the key requirements or acceptance criteria?",
|
||||
description: "List the specific things that need to be true for this task to be considered complete.",
|
||||
};
|
||||
}
|
||||
|
||||
if (historyLength < 3) {
|
||||
return {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there any specific technologies or libraries that should be used?",
|
||||
description: "Answer yes if you have preferences for specific tech stack choices.",
|
||||
};
|
||||
}
|
||||
|
||||
return this.generateSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary from session history.
|
||||
* Stubbed - will be replaced with AI agent integration.
|
||||
*/
|
||||
private generateSummary(): PlanningSummary {
|
||||
const scopeResponse = this.history.find((h) => h.question.id === "q-scope")?.response as
|
||||
| { scope?: string }
|
||||
| undefined;
|
||||
|
||||
const requirementsResponse = this.history.find((h) => h.question.type === "text")?.response as
|
||||
| { requirements?: string }
|
||||
| undefined;
|
||||
|
||||
const suggestedSize =
|
||||
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
|
||||
|
||||
return {
|
||||
title: this.initialPlan.slice(0, 80),
|
||||
description:
|
||||
`${this.initialPlan}\n\n` +
|
||||
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
|
||||
`Generated via Planning Mode`,
|
||||
suggestedSize,
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stubbed AI Integration (to be replaced with real AI agent) ──────────────
|
||||
|
||||
/**
|
||||
* Generate the first question based on the initial plan.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateFirstQuestion(initialPlan: string): PlanningQuestion {
|
||||
// Simple stub: ask about scope
|
||||
return {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: "What is the scope of this plan?",
|
||||
description: "This helps estimate the size and complexity of the task.",
|
||||
options: [
|
||||
{ id: "small", label: "Small - focused change affecting 1-3 files", description: "Quick implementation" },
|
||||
{ id: "medium", label: "Medium - moderate change affecting 3-10 files", description: "Standard feature" },
|
||||
{ id: "large", label: "Large - significant change affecting 10+ files", description: "Complex feature or refactor" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next question or summary based on session history.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateNextQuestionOrSummary(session: Session): PlanningResponse {
|
||||
const historyLength = session.history.length;
|
||||
|
||||
// Simple stub: ask 2-3 questions then generate summary
|
||||
if (historyLength < 2) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: `q-${historyLength + 1}`,
|
||||
type: "text",
|
||||
question: "What are the key requirements or acceptance criteria?",
|
||||
description: "List the specific things that need to be true for this task to be considered complete.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (historyLength < 3) {
|
||||
return {
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there any specific technologies or libraries that should be used?",
|
||||
description: "Answer yes if you have preferences for specific tech stack choices.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Generate summary after 3 questions
|
||||
return {
|
||||
type: "complete",
|
||||
data: generateSummary(session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary from session history.
|
||||
* This is a stub - will be replaced with AI agent.
|
||||
*/
|
||||
function generateSummary(session: Session): PlanningSummary {
|
||||
// Simple stub: create summary from initial plan and history
|
||||
const scopeResponse = session.history.find((h) => h.question.id === "q-scope")?.response as
|
||||
| { scope?: string }
|
||||
| undefined;
|
||||
|
||||
const requirementsResponse = session.history.find((h) => h.question.type === "text")?.response as
|
||||
| { requirements?: string }
|
||||
| undefined;
|
||||
|
||||
const suggestedSize =
|
||||
scopeResponse?.scope === "small" ? "S" : scopeResponse?.scope === "large" ? "L" : "M";
|
||||
|
||||
return {
|
||||
title: session.initialPlan.slice(0, 80),
|
||||
description:
|
||||
`${session.initialPlan}\n\n` +
|
||||
`Requirements: ${requirementsResponse?.requirements || "Standard implementation"}\n\n` +
|
||||
`Generated via Planning Mode`,
|
||||
suggestedSize,
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session Management ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -575,7 +363,7 @@ export async function createSession(
|
||||
ip: string,
|
||||
initialPlan: string,
|
||||
_store?: TaskStore,
|
||||
_rootDir?: string
|
||||
rootDir?: string
|
||||
): Promise<{ sessionId: string; firstQuestion: PlanningQuestion }> {
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(ip)) {
|
||||
@@ -586,27 +374,159 @@ export async function createSession(
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
if (!rootDir) {
|
||||
throw new Error("rootDir is required for AI-powered planning sessions");
|
||||
}
|
||||
|
||||
// Generate first question based on initial plan (stub - maintains backward compatibility)
|
||||
const firstQuestion = generateFirstQuestion(initialPlan);
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ip,
|
||||
initialPlan,
|
||||
history: [],
|
||||
currentQuestion: firstQuestion,
|
||||
thinkingOutput: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
sessions.set(sessionId, session);
|
||||
persistSession(session, "generating");
|
||||
|
||||
// Create AI agent and get the first question
|
||||
// Only await engineReady if createKbAgent hasn't been set externally (e.g., via __setCreateKbAgent)
|
||||
if (!createKbAgent) {
|
||||
await engineReady;
|
||||
}
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: PLANNING_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
onThinking: () => {
|
||||
// Non-streaming path ignores thinking output
|
||||
},
|
||||
onText: () => {
|
||||
// Non-streaming path ignores incremental text
|
||||
},
|
||||
});
|
||||
|
||||
session.agent = agentResult;
|
||||
session.updatedAt = new Date();
|
||||
|
||||
// Send initial plan to get first question from AI
|
||||
const firstQuestion = await getFirstQuestionFromAgent(session, initialPlan);
|
||||
|
||||
session.currentQuestion = firstQuestion;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
|
||||
return { sessionId, firstQuestion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first question from the AI agent by sending the initial plan.
|
||||
* Waits for the agent response and parses it as a PlanningQuestion.
|
||||
* Throws if the agent returns a summary instead of a question.
|
||||
*/
|
||||
async function getFirstQuestionFromAgent(
|
||||
session: Session,
|
||||
message: string
|
||||
): Promise<PlanningQuestion> {
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("AI agent not initialized");
|
||||
}
|
||||
|
||||
// Send message to agent
|
||||
await session.agent.session.prompt(message);
|
||||
|
||||
// Extract response text
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (session.agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let responseText = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
responseText = lastMessage.content;
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
responseText = 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("");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse response with retry
|
||||
let parsed: PlanningResponse | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
parsed = parseAgentResponse(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
try {
|
||||
await session.agent.session.prompt(
|
||||
"Your previous response could not be parsed as JSON. " +
|
||||
'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}}. ' +
|
||||
"No markdown, no explanation, just the JSON."
|
||||
);
|
||||
|
||||
const retryMessage = (session.agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
if (retryMessage?.content) {
|
||||
if (typeof retryMessage.content === "string") {
|
||||
responseText = retryMessage.content;
|
||||
} else if (Array.isArray(retryMessage.content)) {
|
||||
responseText = retryMessage.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("");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
// Clean up the session on failure
|
||||
sessions.delete(session.id);
|
||||
unpersistSession(session.id);
|
||||
throw new Error(
|
||||
`Failed to get first question from AI: ${lastError?.message || "Unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.type === "complete") {
|
||||
// AI returned a summary instead of a question — return a minimal question
|
||||
// so the caller can present the summary
|
||||
const summary = parsed.data;
|
||||
session.summary = summary;
|
||||
persistSession(session, "complete");
|
||||
return {
|
||||
id: "q-direct-summary",
|
||||
type: "confirm",
|
||||
question: `The AI has generated a plan: "${summary.title}". Proceed with this?`,
|
||||
description: summary.description,
|
||||
};
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new planning session with AI agent streaming.
|
||||
* This initializes an AI agent that will stream thinking output via SSE.
|
||||
@@ -1051,33 +971,23 @@ export async function submitResponse(
|
||||
persistSession(session, "generating");
|
||||
|
||||
// If AI agent is active, use it for next question
|
||||
if (session.agent) {
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
// Return the current state (will be updated via SSE)
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
if (session.currentQuestion) {
|
||||
return { type: "question", data: session.currentQuestion };
|
||||
}
|
||||
return { type: "question", data: generateFirstQuestion(session.initialPlan) };
|
||||
if (!session.agent) {
|
||||
throw new InvalidSessionStateError("Planning session has no AI agent");
|
||||
}
|
||||
|
||||
// Stubbed mode: generate next question or summary
|
||||
const result = generateNextQuestionOrSummary(session);
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
await continueAgentConversation(session, message);
|
||||
|
||||
if (result.type === "question") {
|
||||
session.currentQuestion = result.data;
|
||||
} else {
|
||||
session.summary = result.data;
|
||||
session.currentQuestion = undefined;
|
||||
// Return the current state (will be updated via SSE)
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
if (session.currentQuestion) {
|
||||
return { type: "question", data: session.currentQuestion };
|
||||
}
|
||||
|
||||
session.updatedAt = new Date();
|
||||
|
||||
return result;
|
||||
// Should not reach here, but handle gracefully
|
||||
throw new InvalidSessionStateError("AI agent did not return a question or summary");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1256,6 +1166,13 @@ export function __resetPlanningState(): void {
|
||||
planningStreamManager.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock createKbAgent function. Used for testing only.
|
||||
*/
|
||||
export function __setCreateKbAgent(mock: typeof createKbAgent): void {
|
||||
createKbAgent = mock;
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { TaskStore, TaskAttachment } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetBatchImportRateLimiter } from "./routes.js";
|
||||
import { __resetPlanningState } from "./planning.js";
|
||||
import { __resetPlanningState, __setCreateKbAgent } from "./planning.js";
|
||||
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js";
|
||||
import * as terminalServiceModule from "./terminal-service.js";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
@@ -5266,9 +5266,77 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
|
||||
describe("Planning Mode Routes", () => {
|
||||
/** Mock agent for planning session tests */
|
||||
function setupPlanningMockAgent() {
|
||||
const questionResponses = [
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: "What is the scope of this plan?",
|
||||
description: "This helps estimate the size and complexity of the task.",
|
||||
options: [
|
||||
{ id: "small", label: "Small", description: "Quick" },
|
||||
{ id: "medium", label: "Medium", description: "Standard" },
|
||||
{ id: "large", label: "Large", description: "Complex" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-requirements",
|
||||
type: "text",
|
||||
question: "What are the key requirements?",
|
||||
description: "List acceptance criteria.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Are there specific technologies to use?",
|
||||
description: "Answer yes if you have preferences.",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Build a user auth system",
|
||||
description: "Build a user authentication system\n\nRequirements: Standard implementation\n\nGenerated via Planning Mode",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Implementation", "Tests", "Documentation"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
let callIndex = 0;
|
||||
const mockAgent = {
|
||||
session: {
|
||||
state: { messages },
|
||||
prompt: vi.fn(async (msg: string) => {
|
||||
messages.push({ role: "user", content: msg });
|
||||
const response = questionResponses[callIndex++] ?? questionResponses[questionResponses.length - 1];
|
||||
messages.push({ role: "assistant", content: response });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
__setCreateKbAgent(async () => mockAgent);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset planning state before each test to avoid cross-test contamination
|
||||
__resetPlanningState();
|
||||
setupPlanningMockAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__setCreateKbAgent(undefined as any);
|
||||
});
|
||||
|
||||
describe("POST /planning/start", () => {
|
||||
|
||||
@@ -5459,9 +5459,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = store.getRootDir();
|
||||
|
||||
const { createSession, RateLimitError } = await import("./planning.js");
|
||||
const result = await createSession(ip, initialPlan);
|
||||
const result = await createSession(ip, initialPlan, store, rootDir);
|
||||
res.status(201).json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
|
||||
Reference in New Issue
Block a user