feat(KB-159): complete Step 2 — implement planning session SSE infrastructure

This commit is contained in:
gsxdsm
2026-03-30 08:49:54 -07:00
parent cd5fb2806b
commit dbb9791504
2 changed files with 437 additions and 9 deletions

View File

@@ -4,9 +4,13 @@
* Manages AI-guided planning sessions for interactive task creation. * Manages AI-guided planning sessions for interactive task creation.
* Sessions are stored in-memory with TTL cleanup. * Sessions are stored in-memory with TTL cleanup.
* *
* NOTE: AI Agent integration is stubbed for now. When integrating with * Features:
* the real AI agent, update createSession and submitResponse to use * - AI agent integration with real-time streaming via SSE
* createKbAgent from "@kb/engine". * - Rate limiting per IP
* - Session expiration and cleanup
*
* NOTE: AI Agent integration uses createKbAgent from "@kb/engine" for
* real-time planning conversations with thinking output streaming.
*/ */
import type { import type {
@@ -15,8 +19,9 @@ import type {
PlanningResponse, PlanningResponse,
TaskStore, TaskStore,
} from "@kb/core"; } from "@kb/core";
import { createKbAgent } from "@kb/engine"; import { createKbAgent, type AgentResult } from "@kb/engine";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
// ── Constants ─────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────
@@ -50,7 +55,16 @@ When ready to complete, generate:
- A detailed description with context gathered - A detailed description with context gathered
- Size estimate (S/M/L) based on scope - Size estimate (S/M/L) based on scope
- Any suggested dependencies on existing tasks - Any suggested dependencies on existing tasks
- Key deliverables as a checklist`; - Key deliverables as a checklist
## Response Format
Always respond with valid JSON in one of these formats:
For questions:
{\n "type": "question",\n "data": {\n "id": "unique-id",\n "type": "text|single_select|multi_select|confirm",\n "question": "The question text",\n "description": "Helpful context",\n "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]\n }\n}
For completion:
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`;
/** Session TTL in milliseconds (30 minutes) */ /** Session TTL in milliseconds (30 minutes) */
const SESSION_TTL_MS = 30 * 60 * 1000; const SESSION_TTL_MS = 30 * 60 * 1000;
@@ -66,6 +80,17 @@ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
/** SSE event types for planning session streaming */
export type PlanningStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: PlanningSummary }
| { type: "error"; data: string }
| { type: "complete" };
/** Callback function for streaming events */
export type PlanningStreamCallback = (event: PlanningStreamEvent) => void;
interface Session { interface Session {
id: string; id: string;
ip: string; ip: string;
@@ -73,6 +98,12 @@ interface Session {
history: Array<{ question: PlanningQuestion; response: unknown }>; history: Array<{ question: PlanningQuestion; response: unknown }>;
currentQuestion?: PlanningQuestion; currentQuestion?: PlanningQuestion;
summary?: PlanningSummary; summary?: PlanningSummary;
/** AI agent session for real-time interaction */
agent?: AgentResult;
/** Callback for streaming events to SSE clients */
streamCallback?: PlanningStreamCallback;
/** Accumulated thinking output for display */
thinkingOutput: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -132,6 +163,78 @@ process.on("beforeExit", () => {
clearInterval(cleanupInterval); clearInterval(cleanupInterval);
}); });
// ── Planning Stream Manager ─────────────────────────────────────────────────
/**
* Manages SSE connections for active planning sessions.
* Each session can have multiple connected clients receiving streaming updates.
*/
export class PlanningStreamManager extends EventEmitter {
private sessions = new Map<string, Set<PlanningStreamCallback>>();
/**
* Register a client callback for a planning session.
* Returns a function to unsubscribe.
*/
subscribe(sessionId: string, callback: PlanningStreamCallback): () => void {
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, new Set());
}
const callbacks = this.sessions.get(sessionId)!;
callbacks.add(callback);
// Return unsubscribe function
return () => {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.sessions.delete(sessionId);
}
};
}
/**
* Broadcast an event to all clients subscribed to a session.
*/
broadcast(sessionId: string, event: PlanningStreamEvent): void {
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return;
for (const callback of callbacks) {
try {
callback(event);
} catch (err) {
console.error(`[planning] Error broadcasting to client for session ${sessionId}:`, err);
}
}
}
/**
* Check if a session has active subscribers.
*/
hasSubscribers(sessionId: string): boolean {
const callbacks = this.sessions.get(sessionId);
return callbacks !== undefined && callbacks.size > 0;
}
/**
* Get the number of subscribers for a session.
*/
getSubscriberCount(sessionId: string): number {
return this.sessions.get(sessionId)?.size ?? 0;
}
/**
* Clean up all subscriptions for a session.
*/
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
}
}
/** Singleton instance of the planning stream manager */
export const planningStreamManager = new PlanningStreamManager();
// ── Rate Limiting ─────────────────────────────────────────────────────────── // ── Rate Limiting ───────────────────────────────────────────────────────────
/** /**
@@ -397,7 +500,8 @@ function generateSummary(session: Session): PlanningSummary {
/** /**
* Create a new planning session. * Create a new planning session.
* Returns session ID and first question (stubbed for now - AI integration in future). * Uses stubbed AI logic for immediate response (no streaming).
* For streaming AI responses, use createSessionWithAgent.
*/ */
export async function createSession( export async function createSession(
ip: string, ip: string,
@@ -416,7 +520,7 @@ export async function createSession(
const sessionId = randomUUID(); const sessionId = randomUUID();
// Generate first question based on initial plan (stub - AI will do this in future) // Generate first question based on initial plan (stub - maintains backward compatibility)
const firstQuestion = generateFirstQuestion(initialPlan); const firstQuestion = generateFirstQuestion(initialPlan);
const session: Session = { const session: Session = {
@@ -425,6 +529,7 @@ export async function createSession(
initialPlan, initialPlan,
history: [], history: [],
currentQuestion: firstQuestion, currentQuestion: firstQuestion,
thinkingOutput: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -434,9 +539,171 @@ export async function createSession(
return { sessionId, firstQuestion }; return { sessionId, firstQuestion };
} }
/**
* Create a new planning session with AI agent streaming.
* This initializes an AI agent that will stream thinking output via SSE.
*
* @param ip - Client IP for rate limiting
* @param initialPlan - The user's initial plan description
* @param rootDir - Project root directory for AI agent context
* @returns Session ID (use with planningStreamManager to receive events)
*/
export async function createSessionWithAgent(
ip: string,
initialPlan: string,
rootDir: string
): Promise<string> {
// Check rate limit
if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip);
throw new RateLimitError(
`Rate limit exceeded. Maximum ${MAX_SESSIONS_PER_IP_PER_HOUR} planning sessions per hour. ` +
`Reset at ${resetTime?.toISOString() || "unknown"}`
);
}
const sessionId = randomUUID();
const session: Session = {
id: sessionId,
ip,
initialPlan,
history: [],
thinkingOutput: "",
createdAt: new Date(),
updatedAt: new Date(),
};
sessions.set(sessionId, session);
// Initialize AI agent in background - it will stream via planningStreamManager
initializeAgent(session, rootDir).catch((err) => {
console.error(`[planning] Failed to initialize agent for session ${sessionId}:`, err);
planningStreamManager.broadcast(sessionId, {
type: "error",
data: err.message || "Failed to initialize AI agent",
});
});
return sessionId;
}
/**
* Initialize the AI agent for a session and start the first turn.
*/
async function initializeAgent(session: Session, rootDir: string): Promise<void> {
try {
const agentResult = await createKbAgent({
cwd: rootDir,
systemPrompt: PLANNING_SYSTEM_PROMPT,
tools: "readonly",
onThinking: (delta) => {
session.thinkingOutput += delta;
planningStreamManager.broadcast(session.id, {
type: "thinking",
data: delta,
});
},
onText: (delta) => {
// Capture AI response text - will be parsed at end of turn
session.thinkingOutput += delta;
},
});
session.agent = agentResult;
session.updatedAt = new Date();
// Send initial message to get first question
await continueAgentConversation(session, session.initialPlan);
} catch (err) {
console.error(`[planning] Agent initialization error for session ${session.id}:`, err);
planningStreamManager.broadcast(session.id, {
type: "error",
data: err instanceof Error ? err.message : "Failed to initialize AI agent",
});
}
}
/**
* Continue the AI conversation with a user message.
*/
async function continueAgentConversation(session: Session, message: string): Promise<void> {
if (!session.agent) {
throw new InvalidSessionStateError("AI agent not initialized");
}
try {
// Clear thinking output for this turn
const previousThinking = session.thinkingOutput;
session.thinkingOutput = "";
// Send message to agent - it will stream thinking via onThinking callback
const response = await session.agent.session.send(message);
// Combine any thinking output with the response text
const fullResponse = session.thinkingOutput + (response?.text || "");
// Parse the JSON response
const parsed = parseAgentResponse(fullResponse);
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.updatedAt = new Date();
planningStreamManager.broadcast(session.id, {
type: "question",
data: parsed.data,
});
} else if (parsed.type === "complete") {
session.summary = parsed.data;
session.currentQuestion = undefined;
session.updatedAt = new Date();
planningStreamManager.broadcast(session.id, {
type: "summary",
data: parsed.data,
});
planningStreamManager.broadcast(session.id, { type: "complete" });
}
} catch (err) {
console.error(`[planning] Agent conversation error for session ${session.id}:`, err);
planningStreamManager.broadcast(session.id, {
type: "error",
data: err instanceof Error ? err.message : "AI processing failed",
});
}
}
/**
* Parse agent response JSON, with error handling.
*/
function parseAgentResponse(text: string): PlanningResponse {
// Try to extract JSON from the response (AI might wrap in markdown code blocks)
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ||
text.match(/\{[\s\S]*\}/);
const jsonText = jsonMatch ? jsonMatch[1] || jsonMatch[0] : text;
try {
const parsed = JSON.parse(jsonText.trim());
// Validate structure
if (parsed.type === "question" && parsed.data) {
return parsed as PlanningResponse;
}
if (parsed.type === "complete" && parsed.data) {
return parsed as PlanningResponse;
}
// If structure is invalid, treat as error
throw new Error("Invalid response structure from AI");
} catch (err) {
console.error("[planning] Failed to parse agent response:", text);
throw new Error(`Failed to parse AI response: ${err instanceof Error ? err.message : "Unknown error"}`);
}
}
/** /**
* Submit a response to the current question and get the next question or summary. * Submit a response to the current question and get the next question or summary.
* Stubbed - AI integration will be implemented in future. * Supports both stubbed mode and AI agent mode.
*/ */
export async function submitResponse( export async function submitResponse(
sessionId: string, sessionId: string,
@@ -457,7 +724,22 @@ export async function submitResponse(
response: responses, response: responses,
}); });
// Generate next question or summary (stub - AI will do this in future) // 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) };
}
// Stubbed mode: generate next question or summary
const result = generateNextQuestionOrSummary(session); const result = generateNextQuestionOrSummary(session);
if (result.type === "question") { if (result.type === "question") {
@@ -472,6 +754,44 @@ export async function submitResponse(
return result; return result;
} }
/**
* Format user response as a message for the AI agent.
*/
function formatResponseForAgent(
question: PlanningQuestion,
responses: Record<string, unknown>
): string {
const responseValue = responses[question.id];
switch (question.type) {
case "text":
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "single_select":
if (typeof responseValue === "string") {
const option = question.options?.find((o) => o.id === responseValue);
return `Question: ${question.question}\n\nSelected: ${option?.label || responseValue}`;
}
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "multi_select":
if (Array.isArray(responseValue)) {
const selected = responseValue.map((id) => {
const option = question.options?.find((o) => o.id === id);
return option?.label || id;
});
return `Question: ${question.question}\n\nSelected: ${selected.join(", ")}`;
}
return `Question: ${question.question}\n\nAnswer: ${responseValue}`;
case "confirm":
return `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
default:
return `Question: ${question.question}\n\nAnswer: ${JSON.stringify(responseValue)}`;
}
}
/** /**
* Cancel and cleanup a planning session. * Cancel and cleanup a planning session.
*/ */
@@ -481,6 +801,19 @@ export async function cancelSession(sessionId: string): Promise<void> {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`); throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
} }
// Cleanup AI agent if present
if (session.agent) {
try {
session.agent.session.dispose?.();
} catch (err) {
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
}
session.agent = undefined;
}
// Cleanup SSE subscriptions
planningStreamManager.cleanupSession(sessionId);
sessions.delete(sessionId); sessions.delete(sessionId);
} }
@@ -509,6 +842,15 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
* Cleanup a session (used after task creation). * Cleanup a session (used after task creation).
*/ */
export function cleanupSession(sessionId: string): void { export function cleanupSession(sessionId: string): void {
const session = sessions.get(sessionId);
if (session?.agent) {
try {
session.agent.session.dispose?.();
} catch {
// Ignore errors during cleanup
}
}
planningStreamManager.cleanupSession(sessionId);
sessions.delete(sessionId); sessions.delete(sessionId);
} }
@@ -516,8 +858,19 @@ export function cleanupSession(sessionId: string): void {
* Reset all planning state. Used for testing only. * Reset all planning state. Used for testing only.
*/ */
export function __resetPlanningState(): void { export function __resetPlanningState(): void {
// Cleanup all agent sessions
for (const [id, session] of sessions) {
if (session.agent) {
try {
session.agent.session.dispose?.();
} catch {
// Ignore errors during cleanup
}
}
}
sessions.clear(); sessions.clear();
rateLimits.clear(); rateLimits.clear();
planningStreamManager.removeAllListeners();
} }
// ── Custom Errors ─────────────────────────────────────────────────────────── // ── Custom Errors ───────────────────────────────────────────────────────────

View File

@@ -2283,6 +2283,81 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
/**
* GET /api/planning/:sessionId/stream
* SSE endpoint for real-time planning session updates.
* Streams thinking output, questions, summaries, and errors.
*
* Event types:
* - thinking: AI thinking output chunks
* - question: New question to display
* - summary: Planning summary when complete
* - error: Error message
* - complete: Stream completed
*/
router.get("/planning/:sessionId/stream", async (req, res) => {
const { sessionId } = req.params;
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
// Send initial connection confirmation
res.write(": connected\n\n");
try {
const { planningStreamManager, getSession, SessionNotFoundError } = await import("./planning.js");
// Verify session exists
const session = getSession(sessionId);
if (!session) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Session not found or expired" })}\n\n`);
res.end();
return;
}
// Subscribe to session events
const unsubscribe = planningStreamManager.subscribe(sessionId, (event) => {
try {
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data ?? {})}\n\n`);
// End stream on complete or error
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
} catch (err) {
// Client disconnected
unsubscribe();
}
});
// Handle client disconnect
req.on("close", () => {
unsubscribe();
});
// Send heartbeat every 30s to keep connection alive
const heartbeat = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeat);
return;
}
res.write(": heartbeat\n\n");
}, 30_000);
req.on("close", () => {
clearInterval(heartbeat);
});
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "Stream error" })}\n\n`);
res.end();
}
});
/** /**
* GET /api/usage * GET /api/usage
* Fetch AI provider subscription usage (Claude, Codex, Gemini). * Fetch AI provider subscription usage (Claude, Codex, Gemini).