feat(FN-1368): add milestone and slice interview sessions
- Create milestone-slice-interview.ts module for AI-guided milestone/slice planning - Add interview session types (milestone_interview, slice_interview) to AiSessionStore - Wire up server rehydration and automatic session cleanup - Add milestone/slice interview routes to mission router with SSE streaming - Extend MissionStore to persist planningNotes and verification fields - Add comprehensive tests for milestone-slice interview engine
This commit is contained in:
@@ -147,6 +147,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
orderIndex: row.orderIndex,
|
||||
interviewState: row.interviewState as InterviewState,
|
||||
dependencies: fromJson<string[]>(row.dependencies) || [],
|
||||
planningNotes: row.planningNotes || undefined,
|
||||
verification: row.verification || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -165,6 +167,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
orderIndex: row.orderIndex,
|
||||
activatedAt: row.activatedAt || undefined,
|
||||
planState: (row.planState as SlicePlanState) || "not_started",
|
||||
planningNotes: row.planningNotes || undefined,
|
||||
verification: row.verification || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -959,6 +963,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
orderIndex = ?,
|
||||
interviewState = ?,
|
||||
dependencies = ?,
|
||||
planningNotes = ?,
|
||||
verification = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -968,6 +974,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
updated.orderIndex,
|
||||
updated.interviewState,
|
||||
toJson(updated.dependencies),
|
||||
updated.planningNotes ?? null,
|
||||
updated.verification ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
@@ -1159,6 +1167,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
status = ?,
|
||||
orderIndex = ?,
|
||||
activatedAt = ?,
|
||||
planState = ?,
|
||||
planningNotes = ?,
|
||||
verification = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -1167,6 +1178,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
updated.status,
|
||||
updated.orderIndex,
|
||||
updated.activatedAt ?? null,
|
||||
updated.planState,
|
||||
updated.planningNotes ?? null,
|
||||
updated.verification ?? null,
|
||||
updated.updatedAt,
|
||||
updated.id,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Database } from "@fusion/core";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type AiSessionType = "planning" | "subtask" | "mission_interview";
|
||||
export type AiSessionType = "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
|
||||
export type AiSessionStatus = "generating" | "awaiting_input" | "complete" | "error";
|
||||
|
||||
export interface AiSessionRow {
|
||||
|
||||
626
packages/dashboard/src/milestone-slice-interview.test.ts
Normal file
626
packages/dashboard/src/milestone-slice-interview.test.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockCreateKbAgent } = vi.hoisted(() => ({
|
||||
mockCreateKbAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createKbAgent: mockCreateKbAgent,
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetMilestoneSliceInterviewState,
|
||||
cancelTargetInterviewSession,
|
||||
checkRateLimit,
|
||||
cleanupTargetInterviewSession,
|
||||
createTargetInterviewSession,
|
||||
retryTargetInterviewSession,
|
||||
getTargetInterviewSession,
|
||||
getTargetInterviewSummary,
|
||||
getRateLimitResetTime,
|
||||
InvalidSessionStateError,
|
||||
milestoneSliceInterviewStreamManager,
|
||||
parseTargetInterviewResponse,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
RateLimitError,
|
||||
TargetSessionNotFoundError,
|
||||
submitTargetInterviewResponse,
|
||||
applyTargetInterview,
|
||||
skipTargetInterview,
|
||||
MILESTONE_INTERVIEW_SYSTEM_PROMPT,
|
||||
SLICE_INTERVIEW_SYSTEM_PROMPT,
|
||||
type MilestoneInterviewSummary,
|
||||
type SliceInterviewSummary,
|
||||
} from "./milestone-slice-interview.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
function createQuestionJson(id = "q-1"): string {
|
||||
return JSON.stringify({
|
||||
type: "question",
|
||||
data: {
|
||||
id,
|
||||
type: "text",
|
||||
question: "What should we refine first?",
|
||||
description: "Initial scope",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMilestoneCompleteJson(): string {
|
||||
return JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Refined Milestone",
|
||||
description: "Detailed milestone description",
|
||||
planningNotes: "Key planning decisions",
|
||||
verification: "How to verify completion",
|
||||
slices: [
|
||||
{
|
||||
title: "Slice 1",
|
||||
description: "First slice",
|
||||
verification: "Slice 1 verification",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createSliceCompleteJson(): string {
|
||||
return JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Refined Slice",
|
||||
description: "Detailed slice description",
|
||||
planningNotes: "Key planning decisions",
|
||||
verification: "How to verify completion",
|
||||
features: [
|
||||
{
|
||||
title: "Feature 1",
|
||||
description: "First feature",
|
||||
acceptanceCriteria: "AC-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMockAgent(responses: string[]) {
|
||||
const queue = [...responses];
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
let thinkingCb: ((delta: string) => void) | undefined;
|
||||
let textCb: ((delta: string) => void) | undefined;
|
||||
|
||||
const agent: any = {
|
||||
session: {
|
||||
state: { messages },
|
||||
prompt: vi.fn(async () => {
|
||||
const response = queue.shift() ?? createQuestionJson("q-fallback");
|
||||
// Trigger the callbacks with the response (simulates thinking output)
|
||||
thinkingCb?.(response);
|
||||
textCb?.(response);
|
||||
messages.push({ role: "assistant", content: response });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Setup mock to capture callbacks
|
||||
mockCreateKbAgent.mockImplementation(async (options: any) => {
|
||||
thinkingCb = options?.onThinking;
|
||||
textCb = options?.onText;
|
||||
return agent;
|
||||
});
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function waitForCurrentQuestion(sessionId: string): Promise<void> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (getTargetInterviewSession(sessionId)?.currentQuestion) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error("Timed out waiting for currentQuestion");
|
||||
}
|
||||
|
||||
class MockAiSessionStore extends EventEmitter {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
upsert(row: AiSessionRow): void {
|
||||
this.rows.set(row.id, row);
|
||||
}
|
||||
|
||||
updateThinking(id: string, thinkingOutput: string): void {
|
||||
const row = this.rows.get(id);
|
||||
if (!row) return;
|
||||
this.rows.set(id, { ...row, thinkingOutput, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.rows.delete(id);
|
||||
this.emit("ai_session:deleted", id);
|
||||
}
|
||||
|
||||
get(id: string): AiSessionRow | null {
|
||||
return this.rows.get(id) ?? null;
|
||||
}
|
||||
|
||||
listRecoverable(): AiSessionRow[] {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => row.status === "awaiting_input" || row.status === "generating" || row.status === "error",
|
||||
);
|
||||
}
|
||||
|
||||
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
||||
return super.off(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSessionRow(
|
||||
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type ?? "milestone_interview",
|
||||
status: overrides.status,
|
||||
title: overrides.title ?? "Interview planning",
|
||||
inputPayload:
|
||||
overrides.inputPayload ??
|
||||
JSON.stringify({
|
||||
ip: "127.0.0.1",
|
||||
targetType: "milestone",
|
||||
targetId: "ms-123",
|
||||
targetTitle: "Milestone planning",
|
||||
}),
|
||||
conversationHistory:
|
||||
overrides.conversationHistory ??
|
||||
JSON.stringify([
|
||||
{
|
||||
question: {
|
||||
id: "q-1",
|
||||
type: "text",
|
||||
question: "What is your goal?",
|
||||
description: "scope",
|
||||
},
|
||||
response: { "q-1": "Refine this milestone" },
|
||||
},
|
||||
]),
|
||||
currentQuestion:
|
||||
overrides.currentQuestion ??
|
||||
JSON.stringify({
|
||||
id: "q-2",
|
||||
type: "text",
|
||||
question: "Any constraints?",
|
||||
description: "details",
|
||||
}),
|
||||
result: overrides.result ?? null,
|
||||
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
||||
error: overrides.error ?? null,
|
||||
projectId: overrides.projectId ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("milestone-slice-interview module", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
__resetMilestoneSliceInterviewState();
|
||||
// Reset cached createKbAgent to force re-import with mock
|
||||
const mod = await import("./milestone-slice-interview.js") as any;
|
||||
mod.__resetEngine?.();
|
||||
mockCreateKbAgent.mockImplementation(async () => createMockAgent([createQuestionJson()]));
|
||||
});
|
||||
|
||||
describe("session lifecycle", () => {
|
||||
it("creates, retrieves, and cleans up a milestone session", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-123",
|
||||
"Launch Platform",
|
||||
"Mission: Launch Platform v2",
|
||||
"/tmp/project"
|
||||
);
|
||||
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.targetType).toBe("milestone");
|
||||
expect(session?.targetId).toBe("ms-123");
|
||||
expect(session?.targetTitle).toBe("Launch Platform");
|
||||
expect(session?.missionContext).toBe("Mission: Launch Platform v2");
|
||||
|
||||
cleanupTargetInterviewSession(sessionId);
|
||||
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates, retrieves, and cleans up a slice session", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"slice",
|
||||
"sl-456",
|
||||
"Auth System",
|
||||
"Mission: Launch v2 | Milestone: Foundation",
|
||||
"/tmp/project"
|
||||
);
|
||||
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.targetType).toBe("slice");
|
||||
expect(session?.targetId).toBe("sl-456");
|
||||
expect(session?.targetTitle).toBe("Auth System");
|
||||
expect(session?.missionContext).toBe("Mission: Launch v2 | Milestone: Foundation");
|
||||
|
||||
cleanupTargetInterviewSession(sessionId);
|
||||
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancels a session and throws when canceling missing session", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-789",
|
||||
"Cancel mission",
|
||||
undefined,
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
await cancelTargetInterviewSession(sessionId);
|
||||
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||
|
||||
await expect(cancelTargetInterviewSession("non-existent")).rejects.toThrow(TargetSessionNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limiting", () => {
|
||||
it("enforces max sessions per IP per hour", () => {
|
||||
// Simulate 5 requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(checkRateLimit("192.168.1.1")).toBe(true);
|
||||
}
|
||||
// 6th should be blocked
|
||||
expect(checkRateLimit("192.168.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows different IPs", () => {
|
||||
expect(checkRateLimit("192.168.1.1")).toBe(true);
|
||||
expect(checkRateLimit("192.168.1.2")).toBe(true);
|
||||
expect(checkRateLimit("192.168.1.3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rehydration", () => {
|
||||
it("rehydrates milestone_interview sessions from recoverable rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSessionRow({
|
||||
id: "rehydrated-ms",
|
||||
status: "awaiting_input",
|
||||
type: "milestone_interview",
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
const count = rehydrateFromStore(store);
|
||||
expect(count).toBe(1);
|
||||
expect(getTargetInterviewSession("rehydrated-ms")).toBeDefined();
|
||||
});
|
||||
|
||||
it("rehydrates slice_interview sessions from recoverable rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSessionRow({
|
||||
id: "rehydrated-sl",
|
||||
status: "awaiting_input",
|
||||
type: "slice_interview",
|
||||
inputPayload: JSON.stringify({
|
||||
ip: "127.0.0.1",
|
||||
targetType: "slice",
|
||||
targetId: "sl-123",
|
||||
targetTitle: "Slice planning",
|
||||
}),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
|
||||
const count = rehydrateFromStore(store);
|
||||
expect(count).toBe(1);
|
||||
const session = getTargetInterviewSession("rehydrated-sl");
|
||||
expect(session?.targetType).toBe("slice");
|
||||
});
|
||||
|
||||
it("skips corrupted rows and continues with valid rows", () => {
|
||||
const store = new MockAiSessionStore();
|
||||
store.rows.set("valid-row", buildSessionRow({
|
||||
id: "valid-row",
|
||||
status: "awaiting_input",
|
||||
type: "milestone_interview",
|
||||
}));
|
||||
// Add a corrupted row
|
||||
store.rows.set("corrupted-row", {
|
||||
...buildSessionRow({ id: "corrupted-row", status: "awaiting_input" }),
|
||||
conversationHistory: "invalid json {{{",
|
||||
} as AiSessionRow);
|
||||
|
||||
const count = rehydrateFromStore(store);
|
||||
expect(count).toBe(1);
|
||||
expect(getTargetInterviewSession("valid-row")).toBeDefined();
|
||||
expect(getTargetInterviewSession("corrupted-row")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls through to SQLite when in-memory session is missing", () => {
|
||||
const sessionId = "sqlite-fallback";
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildSessionRow({
|
||||
id: sessionId,
|
||||
status: "awaiting_input",
|
||||
type: "slice_interview",
|
||||
inputPayload: JSON.stringify({
|
||||
ip: "127.0.0.1",
|
||||
targetType: "slice",
|
||||
targetId: "sl-456",
|
||||
targetTitle: "SQLite fallback",
|
||||
}),
|
||||
});
|
||||
store.rows.set(sessionId, row);
|
||||
|
||||
// No in-memory session yet
|
||||
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||
|
||||
// Should fall through to SQLite
|
||||
setAiSessionStore(store);
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.targetId).toBe("sl-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitTargetInterviewResponse", () => {
|
||||
// Note: Full end-to-end tests with AI completion are complex due to async agent mocking.
|
||||
// These tests verify the response submission path.
|
||||
|
||||
it("throws error for unknown session", async () => {
|
||||
await expect(
|
||||
submitTargetInterviewResponse("unknown-session", { "q-1": "test" }, "/tmp")
|
||||
).rejects.toThrow(TargetSessionNotFoundError);
|
||||
});
|
||||
|
||||
it("throws error when no active question", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-no-q",
|
||||
"Test",
|
||||
undefined,
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
// Manually clear the question to simulate state
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (session) {
|
||||
session.currentQuestion = undefined;
|
||||
}
|
||||
|
||||
await expect(
|
||||
submitTargetInterviewResponse(sessionId, { "q-1": "test" }, "/tmp")
|
||||
).rejects.toThrow(InvalidSessionStateError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryTargetInterviewSession", () => {
|
||||
it("replays initial prompt when history is empty", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-retry",
|
||||
"Retry Test",
|
||||
undefined,
|
||||
"/tmp/project"
|
||||
);
|
||||
|
||||
// Mark session as errored
|
||||
const store = new MockAiSessionStore();
|
||||
store.rows.set(sessionId, buildSessionRow({
|
||||
id: sessionId,
|
||||
status: "error",
|
||||
type: "milestone_interview",
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
error: "Previous error",
|
||||
}));
|
||||
setAiSessionStore(store);
|
||||
|
||||
mockCreateKbAgent.mockImplementation(async () => createMockAgent([createQuestionJson()]));
|
||||
|
||||
await expect(retryTargetInterviewSession(sessionId, "/tmp/project")).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when retrying a non-error session", async () => {
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"slice",
|
||||
"sl-no-error",
|
||||
"Not Error",
|
||||
undefined,
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
await expect(retryTargetInterviewSession(sessionId, "/tmp")).rejects.toThrow(
|
||||
InvalidSessionStateError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTargetInterview", () => {
|
||||
// Note: Full end-to-end tests with AI completion are complex due to async agent mocking.
|
||||
// These tests verify error handling.
|
||||
|
||||
it("throws when session not found", () => {
|
||||
const mockMissionStore = {} as any;
|
||||
expect(() => applyTargetInterview("missing", mockMissionStore)).toThrow(
|
||||
TargetSessionNotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipTargetInterview", () => {
|
||||
it("skips milestone interview and applies mission-level context", () => {
|
||||
const mockUpdateMilestone = vi.fn().mockReturnValue({ id: "ms-skip", title: "Skipped" });
|
||||
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-skip", title: "Skip Test", missionId: "m-1" });
|
||||
const mockGetMission = vi.fn().mockReturnValue({ id: "m-1", title: "Parent Mission", description: "Mission desc" });
|
||||
const mockMissionStore = {
|
||||
getMilestone: mockGetMilestone,
|
||||
updateMilestone: mockUpdateMilestone,
|
||||
getMission: mockGetMission,
|
||||
} as any;
|
||||
|
||||
const result = skipTargetInterview("milestone", "ms-skip", mockMissionStore);
|
||||
|
||||
expect(mockUpdateMilestone).toHaveBeenCalledWith("ms-skip", expect.objectContaining({
|
||||
interviewState: "completed",
|
||||
}));
|
||||
const updateCall = mockUpdateMilestone.mock.calls[0][1];
|
||||
expect(updateCall.planningNotes).toContain("Planned using mission-level context");
|
||||
expect(updateCall.planningNotes).toContain("Parent Mission");
|
||||
});
|
||||
|
||||
it("skips slice interview and applies mission-level context", () => {
|
||||
const mockUpdateSlice = vi.fn().mockReturnValue({ id: "sl-skip", title: "Skipped" });
|
||||
const mockGetSlice = vi.fn().mockReturnValue({ id: "sl-skip", title: "Skip Test", milestoneId: "ms-1" });
|
||||
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-1", title: "Parent Milestone", missionId: "m-1" });
|
||||
const mockGetMission = vi.fn().mockReturnValue({ id: "m-1", title: "Parent Mission", description: "Mission desc" });
|
||||
const mockMissionStore = {
|
||||
getSlice: mockGetSlice,
|
||||
updateSlice: mockUpdateSlice,
|
||||
getMilestone: mockGetMilestone,
|
||||
getMission: mockGetMission,
|
||||
} as any;
|
||||
|
||||
const result = skipTargetInterview("slice", "sl-skip", mockMissionStore);
|
||||
|
||||
expect(mockUpdateSlice).toHaveBeenCalledWith("sl-skip", expect.objectContaining({
|
||||
planState: "planned",
|
||||
}));
|
||||
const updateCall = mockUpdateSlice.mock.calls[0][1];
|
||||
expect(updateCall.planningNotes).toContain("Planned using mission-level context");
|
||||
expect(updateCall.planningNotes).toContain("Parent Mission");
|
||||
expect(updateCall.planningNotes).toContain("Parent Milestone");
|
||||
});
|
||||
|
||||
it("throws when milestone not found", () => {
|
||||
const mockMissionStore = {
|
||||
getMilestone: vi.fn().mockReturnValue(undefined),
|
||||
} as any;
|
||||
expect(() => skipTargetInterview("milestone", "missing", mockMissionStore)).toThrow(
|
||||
TargetSessionNotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stream manager", () => {
|
||||
it("subscribes, broadcasts, and cleans up", () => {
|
||||
const events: any[] = [];
|
||||
const unsubscribe = milestoneSliceInterviewStreamManager.subscribe("stream-test", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
milestoneSliceInterviewStreamManager.broadcast("stream-test", { type: "thinking", data: "thinking..." });
|
||||
milestoneSliceInterviewStreamManager.broadcast("stream-test", { type: "question", data: { id: "q-1" } });
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
|
||||
unsubscribe();
|
||||
milestoneSliceInterviewStreamManager.broadcast("stream-test", { type: "complete" });
|
||||
expect(events.length).toBe(2); // No new event after unsubscribe
|
||||
});
|
||||
|
||||
it("returns buffered events since last event id", () => {
|
||||
milestoneSliceInterviewStreamManager.broadcast("buffer-test", { type: "thinking", data: "1" });
|
||||
milestoneSliceInterviewStreamManager.broadcast("buffer-test", { type: "thinking", data: "2" });
|
||||
milestoneSliceInterviewStreamManager.broadcast("buffer-test", { type: "thinking", data: "3" });
|
||||
|
||||
const events = milestoneSliceInterviewStreamManager.getBufferedEvents("buffer-test", 1);
|
||||
expect(events.length).toBe(2); // Events with id 2 and 3
|
||||
});
|
||||
|
||||
it("clears buffered events on cleanup", () => {
|
||||
milestoneSliceInterviewStreamManager.broadcast("cleanup-test", { type: "complete" });
|
||||
milestoneSliceInterviewStreamManager.cleanupSession("cleanup-test");
|
||||
|
||||
const events = milestoneSliceInterviewStreamManager.getBufferedEvents("cleanup-test", 0);
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response parsing", () => {
|
||||
it("parses milestone complete response with all fields", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Milestone Title",
|
||||
description: "Description",
|
||||
planningNotes: "Notes",
|
||||
verification: "Verify",
|
||||
slices: [
|
||||
{ title: "Slice 1", verification: "V1" },
|
||||
{ title: "Slice 2", description: "D2" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseTargetInterviewResponse(json);
|
||||
expect(result.type).toBe("complete");
|
||||
const data = result.data as MilestoneInterviewSummary;
|
||||
expect(data.title).toBe("Milestone Title");
|
||||
expect(data.description).toBe("Description");
|
||||
expect(data.planningNotes).toBe("Notes");
|
||||
expect(data.verification).toBe("Verify");
|
||||
expect(data.slices).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses slice complete response with features", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Slice Title",
|
||||
description: "Slice Description",
|
||||
planningNotes: "Slice Notes",
|
||||
verification: "Verify Slice",
|
||||
features: [
|
||||
{ title: "Feature 1", acceptanceCriteria: "AC1" },
|
||||
{ title: "Feature 2", description: "F2 Desc" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseTargetInterviewResponse(json);
|
||||
expect(result.type).toBe("complete");
|
||||
const data = result.data as SliceInterviewSummary;
|
||||
expect(data.title).toBe("Slice Title");
|
||||
expect(data.features).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system prompts", () => {
|
||||
it("has milestone interview system prompt", () => {
|
||||
expect(MILESTONE_INTERVIEW_SYSTEM_PROMPT).toContain("milestone");
|
||||
expect(MILESTONE_INTERVIEW_SYSTEM_PROMPT).toContain("slice");
|
||||
expect(MILESTONE_INTERVIEW_SYSTEM_PROMPT).toContain("verification");
|
||||
});
|
||||
|
||||
it("has slice interview system prompt", () => {
|
||||
expect(SLICE_INTERVIEW_SYSTEM_PROMPT).toContain("slice");
|
||||
expect(SLICE_INTERVIEW_SYSTEM_PROMPT).toContain("feature");
|
||||
expect(SLICE_INTERVIEW_SYSTEM_PROMPT).toContain("acceptanceCriteria");
|
||||
});
|
||||
});
|
||||
});
|
||||
1212
packages/dashboard/src/milestone-slice-interview.ts
Normal file
1212
packages/dashboard/src/milestone-slice-interview.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3256,4 +3256,293 @@ describe("Mission API", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Milestone Interview Routes ───────────────────────────────────────────────
|
||||
|
||||
describe("milestone interview routes", () => {
|
||||
function createMilestoneMockAiSessionStore() {
|
||||
const store = new Map<string, any>();
|
||||
return {
|
||||
store,
|
||||
upsert: vi.fn((row) => store.set(row.id, row)),
|
||||
get: vi.fn((id) => store.get(id) ?? null),
|
||||
delete: vi.fn((id) => store.delete(id)),
|
||||
listRecoverable: vi.fn(() => Array.from(store.values())),
|
||||
acquireLock: vi.fn().mockReturnValue({ acquired: true, currentHolder: null }),
|
||||
};
|
||||
}
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/start creates session and returns 201", async () => {
|
||||
const aiSessionStore = createMilestoneMockAiSessionStore();
|
||||
const { app, missionStore } = buildApp({ aiSessionStore });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const createSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"createTargetInterviewSession"
|
||||
).mockResolvedValueOnce("session-123");
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toHaveProperty("sessionId", "session-123");
|
||||
expect(createSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/start returns 404 for missing milestone", async () => {
|
||||
const { app } = buildApp({});
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/MS-NOT-FOUND/interview/start",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/start returns 400 for invalid milestone ID", async () => {
|
||||
const { app } = buildApp({});
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/invalid-id/interview/start",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/respond returns 200 with question/summary", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const submitSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"submitTargetInterviewResponse"
|
||||
).mockResolvedValueOnce({
|
||||
type: "question",
|
||||
data: { id: "q-1", type: "text", question: "Next question?" },
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/MS-TEST1/interview/respond",
|
||||
JSON.stringify({ sessionId: "session-123", responses: { "q-1": "answer" } }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("question");
|
||||
expect(submitSpy).toHaveBeenCalledWith("session-123", { "q-1": "answer" }, expect.any(String));
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/respond returns 400 for missing sessionId", async () => {
|
||||
const { app } = buildApp({});
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/MS-TEST1/interview/respond",
|
||||
JSON.stringify({ responses: {} }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/apply returns 200 with updated milestone", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const applySpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"applyTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...milestone,
|
||||
planningNotes: "Interview notes",
|
||||
verification: "Verification criteria",
|
||||
interviewState: "completed",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/interview/apply`,
|
||||
JSON.stringify({ sessionId: "session-123" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.interviewState).toBe("completed");
|
||||
expect(applySpy).toHaveBeenCalledWith("session-123", expect.anything());
|
||||
});
|
||||
|
||||
it("POST /milestones/:milestoneId/interview/skip returns 200 with updated milestone", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
|
||||
const skipSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"skipTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...milestone,
|
||||
planningNotes: "Planned using mission-level context",
|
||||
interviewState: "completed",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/interview/skip`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(skipSpy).toHaveBeenCalledWith("milestone", milestone.id, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice Interview Routes ─────────────────────────────────────────────────
|
||||
|
||||
describe("slice interview routes", () => {
|
||||
it("POST /slices/:sliceId/interview/start creates session and returns 201", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const createSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"createTargetInterviewSession"
|
||||
).mockResolvedValueOnce("session-456");
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toHaveProperty("sessionId", "session-456");
|
||||
expect(createSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST /slices/:sliceId/interview/start returns 404 for missing slice", async () => {
|
||||
const { app } = buildApp({});
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/slices/SL-NOT-FOUND/interview/start",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /slices/:sliceId/interview/respond returns 200 with question/summary", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const submitSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"submitTargetInterviewResponse"
|
||||
).mockResolvedValueOnce({
|
||||
type: "complete",
|
||||
data: {
|
||||
title: "Refined Slice",
|
||||
description: "Updated description",
|
||||
planningNotes: "Notes",
|
||||
verification: "Verification",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/slices/SL-TEST1/interview/respond",
|
||||
JSON.stringify({ sessionId: "session-456", responses: { "q-1": "answer" } }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("complete");
|
||||
});
|
||||
|
||||
it("POST /slices/:sliceId/interview/apply returns 200 with updated slice", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const applySpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"applyTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...slice,
|
||||
planningNotes: "Interview notes",
|
||||
verification: "Verification criteria",
|
||||
planState: "planned",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/interview/apply`,
|
||||
JSON.stringify({ sessionId: "session-456" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.planState).toBe("planned");
|
||||
});
|
||||
|
||||
it("POST /slices/:sliceId/interview/skip returns 200 with updated slice", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Test Mission" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Test Slice" });
|
||||
|
||||
const skipSpy = vi.spyOn(
|
||||
await import("./milestone-slice-interview.js"),
|
||||
"skipTargetInterview"
|
||||
).mockReturnValueOnce({
|
||||
...slice,
|
||||
planningNotes: "Planned using mission-level context",
|
||||
planState: "planned",
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/interview/skip`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(skipSpy).toHaveBeenCalledWith("slice", slice.id, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -479,7 +479,7 @@ export function getRateLimitResetTime(ip: string): Date | null {
|
||||
* Extract the best JSON candidate from AI response text.
|
||||
* Handles markdown-wrapped JSON, embedded prose, and multiple objects.
|
||||
*/
|
||||
function extractJsonCandidate(text: string): string | null {
|
||||
export function extractJsonCandidate(text: string): string | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
// 1. Try markdown code blocks first
|
||||
@@ -531,7 +531,7 @@ function extractJsonCandidate(text: string): string | null {
|
||||
/**
|
||||
* Attempt to repair common JSON issues.
|
||||
*/
|
||||
function repairJson(text: string): string {
|
||||
export function repairJson(text: string): string {
|
||||
let repaired = text;
|
||||
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
|
||||
|
||||
|
||||
@@ -2019,6 +2019,680 @@ export function createMissionRouter(
|
||||
})
|
||||
);
|
||||
|
||||
// ── Milestone Interview Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/start
|
||||
* Start a milestone interview session with AI agent streaming.
|
||||
* Returns: { sessionId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/start",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
throw badRequest("Invalid milestone ID format");
|
||||
}
|
||||
|
||||
const milestone = missionStore.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw notFound("Milestone not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
|
||||
// Get mission context for the interview
|
||||
const mission = missionStore.getMission(milestone.missionId);
|
||||
const missionContext = mission
|
||||
? `Mission: "${mission.title}". ${mission.description || ""}`
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
createTargetInterviewSession,
|
||||
RateLimitError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
ip,
|
||||
"milestone",
|
||||
milestoneId,
|
||||
milestone.title,
|
||||
missionContext,
|
||||
rootDir
|
||||
);
|
||||
res.status(201).json({ sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
throw rateLimited(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to start interview session");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/respond
|
||||
* Submit response to milestone interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string }
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/respond",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId, responses, tabId } = req.body;
|
||||
|
||||
if (!validateMilestoneId(req.params.milestoneId)) {
|
||||
throw badRequest("Invalid milestone ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
if (!responses || typeof responses !== "object") {
|
||||
throw badRequest("responses is required and must be an object");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
submitTargetInterviewResponse,
|
||||
TargetSessionNotFoundError,
|
||||
TargetInvalidSessionStateError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "TargetInvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to process response");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /milestones/:milestoneId/interview/:sessionId/stream
|
||||
* SSE endpoint for real-time milestone interview session updates.
|
||||
* Streams thinking output, questions, summaries, and errors.
|
||||
*/
|
||||
router.get(
|
||||
"/milestones/:milestoneId/interview/:sessionId/stream",
|
||||
catchTypedHandler(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 {
|
||||
milestoneSliceInterviewStreamManager: msStreamManager,
|
||||
getTargetInterviewSession,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
// Verify session exists
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" }));
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEventId = parseLastEventId(req);
|
||||
if (lastEventId !== undefined) {
|
||||
const buffered = msStreamManager.getBufferedEvents(sessionId, lastEventId);
|
||||
if (!replayBufferedSSE(res, buffered)) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (session.summary) {
|
||||
const existing = msStreamManager.getBufferedEvents(sessionId, 0);
|
||||
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
|
||||
const summaryEventId = lastSummaryEvent?.id
|
||||
?? msStreamManager.broadcast(sessionId, {
|
||||
type: "summary",
|
||||
data: session.summary,
|
||||
});
|
||||
|
||||
if (lastEventId === undefined || summaryEventId > lastEventId) {
|
||||
if (!writeSSEEvent(res, "summary", JSON.stringify(session.summary), summaryEventId)) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete");
|
||||
const completeEventId = lastCompleteEvent?.id
|
||||
?? msStreamManager.broadcast(sessionId, { type: "complete" });
|
||||
|
||||
if (lastEventId === undefined || completeEventId > lastEventId) {
|
||||
writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId);
|
||||
}
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to session events
|
||||
const unsubscribe = msStreamManager.subscribe(sessionId, (event, eventId) => {
|
||||
const data = (event as { data?: unknown }).data;
|
||||
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
// End stream on complete or error
|
||||
if (event.type === "complete" || event.type === "error") {
|
||||
unsubscribe();
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on("close", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Heartbeat every 30s
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
} catch (err: any) {
|
||||
writeSSEEvent(res, "error", JSON.stringify({ message: err.message || "Stream error" }));
|
||||
res.end();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/:sessionId/retry
|
||||
* Retry a failed milestone interview session.
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/:sessionId/retry",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(req.params.milestoneId)) {
|
||||
throw badRequest("Invalid milestone ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
retryTargetInterviewSession,
|
||||
TargetSessionNotFoundError,
|
||||
TargetInvalidSessionStateError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
await retryTargetInterviewSession(sessionId, rootDir);
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "TargetInvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to retry interview session");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/apply
|
||||
* Apply milestone interview summary to the milestone.
|
||||
* Body: { sessionId: string, summary?: TargetInterviewSummary }
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/apply",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!validateMilestoneId(req.params.milestoneId)) {
|
||||
throw badRequest("Invalid milestone ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
applyTargetInterview,
|
||||
TargetSessionNotFoundError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const milestone = applyTargetInterview(sessionId, missionStore);
|
||||
res.json(milestone);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to apply interview");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /milestones/:milestoneId/interview/skip
|
||||
* Skip milestone interview and apply mission-level context.
|
||||
*/
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview/skip",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
throw badRequest("Invalid milestone ID format");
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
skipTargetInterview,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const milestone = skipTargetInterview("milestone", milestoneId, missionStore);
|
||||
res.json(milestone);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to skip interview");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ── Slice Interview Routes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/start
|
||||
* Start a slice interview session with AI agent streaming.
|
||||
* Returns: { sessionId: string }
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/start",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
throw badRequest("Invalid slice ID format");
|
||||
}
|
||||
|
||||
const slice = missionStore.getSlice(sliceId);
|
||||
if (!slice) {
|
||||
throw notFound("Slice not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
|
||||
// Get mission hierarchy context for the interview
|
||||
const milestone = missionStore.getMilestone(slice.milestoneId);
|
||||
const mission = milestone ? missionStore.getMission(milestone.missionId) : undefined;
|
||||
const missionContext = mission && milestone
|
||||
? `Mission: "${mission.title}". Milestone: "${milestone.title}". ${mission.description || ""}`
|
||||
: milestone
|
||||
? `Milestone: "${milestone.title}".`
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
createTargetInterviewSession,
|
||||
RateLimitError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
ip,
|
||||
"slice",
|
||||
sliceId,
|
||||
slice.title,
|
||||
missionContext,
|
||||
rootDir
|
||||
);
|
||||
res.status(201).json({ sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
throw rateLimited(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to start interview session");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/respond
|
||||
* Submit response to slice interview question.
|
||||
* Body: { sessionId: string, responses: Record<string, unknown>, tabId?: string }
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/respond",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId, responses, tabId } = req.body;
|
||||
|
||||
if (!validateSliceId(req.params.sliceId)) {
|
||||
throw badRequest("Invalid slice ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
if (!responses || typeof responses !== "object") {
|
||||
throw badRequest("responses is required and must be an object");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
submitTargetInterviewResponse,
|
||||
TargetSessionNotFoundError,
|
||||
TargetInvalidSessionStateError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "TargetInvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to process response");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /slices/:sliceId/interview/:sessionId/stream
|
||||
* SSE endpoint for real-time slice interview session updates.
|
||||
* Streams thinking output, questions, summaries, and errors.
|
||||
*/
|
||||
router.get(
|
||||
"/slices/:sliceId/interview/:sessionId/stream",
|
||||
catchTypedHandler(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 {
|
||||
milestoneSliceInterviewStreamManager: msStreamManager,
|
||||
getTargetInterviewSession,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
// Verify session exists
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" }));
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastEventId = parseLastEventId(req);
|
||||
if (lastEventId !== undefined) {
|
||||
const buffered = msStreamManager.getBufferedEvents(sessionId, lastEventId);
|
||||
if (!replayBufferedSSE(res, buffered)) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (session.summary) {
|
||||
const existing = msStreamManager.getBufferedEvents(sessionId, 0);
|
||||
const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary");
|
||||
const summaryEventId = lastSummaryEvent?.id
|
||||
?? msStreamManager.broadcast(sessionId, {
|
||||
type: "summary",
|
||||
data: session.summary,
|
||||
});
|
||||
|
||||
if (lastEventId === undefined || summaryEventId > lastEventId) {
|
||||
if (!writeSSEEvent(res, "summary", JSON.stringify(session.summary), summaryEventId)) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete");
|
||||
const completeEventId = lastCompleteEvent?.id
|
||||
?? msStreamManager.broadcast(sessionId, { type: "complete" });
|
||||
|
||||
if (lastEventId === undefined || completeEventId > lastEventId) {
|
||||
writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId);
|
||||
}
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to session events
|
||||
const unsubscribe = msStreamManager.subscribe(sessionId, (event, eventId) => {
|
||||
const data = (event as { data?: unknown }).data;
|
||||
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
// End stream on complete or error
|
||||
if (event.type === "complete" || event.type === "error") {
|
||||
unsubscribe();
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on("close", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Heartbeat every 30s
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
} catch (err: any) {
|
||||
writeSSEEvent(res, "error", JSON.stringify({ message: err.message || "Stream error" }));
|
||||
res.end();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/:sessionId/retry
|
||||
* Retry a failed slice interview session.
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/:sessionId/retry",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!validateSliceId(req.params.sliceId)) {
|
||||
throw badRequest("Invalid slice ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
retryTargetInterviewSession,
|
||||
TargetSessionNotFoundError,
|
||||
TargetInvalidSessionStateError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
await retryTargetInterviewSession(sessionId, rootDir);
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else if (err.name === "TargetInvalidSessionStateError") {
|
||||
throw badRequest(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to retry interview session");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/apply
|
||||
* Apply slice interview summary to the slice.
|
||||
* Body: { sessionId: string, summary?: TargetInterviewSummary }
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/apply",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!validateSliceId(req.params.sliceId)) {
|
||||
throw badRequest("Invalid slice ID format");
|
||||
}
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
applyTargetInterview,
|
||||
TargetSessionNotFoundError,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const slice = applyTargetInterview(sessionId, missionStore);
|
||||
res.json(slice);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to apply interview");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /slices/:sliceId/interview/skip
|
||||
* Skip slice interview and apply mission-level context.
|
||||
*/
|
||||
router.post(
|
||||
"/slices/:sliceId/interview/skip",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
throw badRequest("Invalid slice ID format");
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
skipTargetInterview,
|
||||
} = await import("./milestone-slice-interview.js");
|
||||
|
||||
const slice = skipTargetInterview("slice", sliceId, missionStore);
|
||||
res.json(slice);
|
||||
} catch (err: any) {
|
||||
if (err.name === "TargetSessionNotFoundError") {
|
||||
throw notFound(err.message);
|
||||
} else {
|
||||
throw internalError(err.message || "Failed to skip interview");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
SessionNotFoundError as AgentGenerationSessionNotFoundError,
|
||||
} from "./agent-generation.js";
|
||||
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
|
||||
import { writeSSEEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -10550,6 +10551,12 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Session may not belong to mission interview or may already be cleaned up.
|
||||
}
|
||||
|
||||
try {
|
||||
if (getTargetInterviewSession(id)) cleanupTargetInterviewSession(id);
|
||||
} catch {
|
||||
// Session may not belong to milestone/slice interview or may already be cleaned up.
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
setAiSessionStore as setMissionAiSessionStore,
|
||||
rehydrateFromStore as rehydrateMissionSessions,
|
||||
} from "./mission-interview.js";
|
||||
import {
|
||||
setAiSessionStore as setMilestoneSliceAiSessionStore,
|
||||
rehydrateFromStore as rehydrateMilestoneSliceSessions,
|
||||
} from "./milestone-slice-interview.js";
|
||||
import { ChatManager } from "./chat.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -379,15 +383,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
setPlanningAiSessionStore(aiSessionStore);
|
||||
setSubtaskAiSessionStore(aiSessionStore);
|
||||
setMissionAiSessionStore(aiSessionStore);
|
||||
setMilestoneSliceAiSessionStore(aiSessionStore);
|
||||
|
||||
const planningRehydratedCount = rehydratePlanningSessions(aiSessionStore);
|
||||
const subtaskRehydratedCount = rehydrateSubtaskSessions(aiSessionStore);
|
||||
const missionRehydratedCount = rehydrateMissionSessions(aiSessionStore);
|
||||
const milestoneSliceRehydratedCount = rehydrateMilestoneSliceSessions(aiSessionStore);
|
||||
const totalRehydrated =
|
||||
planningRehydratedCount + subtaskRehydratedCount + missionRehydratedCount;
|
||||
planningRehydratedCount + subtaskRehydratedCount + missionRehydratedCount + milestoneSliceRehydratedCount;
|
||||
if (totalRehydrated > 0) {
|
||||
console.log(
|
||||
`[server] Rehydrated ${planningRehydratedCount} planning, ${subtaskRehydratedCount} subtask, ${missionRehydratedCount} mission sessions from SQLite`,
|
||||
`[server] Rehydrated ${planningRehydratedCount} planning, ${subtaskRehydratedCount} subtask, ${missionRehydratedCount} mission, ${milestoneSliceRehydratedCount} milestone/slice sessions from SQLite`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user