feat(FN-4620): complete Step 4 — add milestone/slice interview board tools

Fusion-Task-Id: FN-4620
Fusion-Task-Lineage: d3d05e54-e137-46ee-bb5d-d71aa03e5a48
This commit is contained in:
Fusion
2026-05-15 11:32:40 -07:00
committed by gsxdsm
parent 0e44bd81c7
commit 808103b5ad
2 changed files with 52 additions and 22 deletions

View File

@@ -15,7 +15,7 @@
* - Unified session type for both milestone and slice interviews * - Unified session type for both milestone and slice interviews
*/ */
import type { PlanningQuestion, Milestone, Slice, MissionStore, InterviewState, SlicePlanState } from "@fusion/core"; import type { PlanningQuestion, Milestone, Slice, MissionStore, InterviewState, SlicePlanState, TaskStore } from "@fusion/core";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js"; import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
@@ -96,6 +96,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse }; export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse };
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import { createPlanningBoardTools } from "./planning-board-tools.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any; type AgentResult = any;
@@ -166,6 +167,11 @@ A milestone represents a major phase or deliverable within a larger mission. Eac
- Milestone: "verification" field — how to confirm this phase is complete - Milestone: "verification" field — how to confirm this phase is complete
- Slice: "verification" field — how to confirm this work unit is done - Slice: "verification" field — how to confirm this work unit is done
## Board tools
- fn_task_list — list active tasks
- fn_task_get — read a task's full details and PROMPT.md
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
## Response Format ## Response Format
Always respond with valid JSON in one of these formats: Always respond with valid JSON in one of these formats:
@@ -210,6 +216,11 @@ A slice represents a focused work unit within a milestone that can be activated
- Slice: "verification" field — how to confirm this work unit is done - Slice: "verification" field — how to confirm this work unit is done
- Feature: "acceptanceCriteria" field — how to verify this specific deliverable - Feature: "acceptanceCriteria" field — how to verify this specific deliverable
## Board tools
- fn_task_list — list active tasks
- fn_task_get — read a task's full details and PROMPT.md
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
## Response Format ## Response Format
Always respond with valid JSON in one of these formats: Always respond with valid JSON in one of these formats:
@@ -717,6 +728,7 @@ function getSystemPrompt(targetType: TargetType): string {
async function createTargetInterviewAgent( async function createTargetInterviewAgent(
session: TargetInterviewSession, session: TargetInterviewSession,
rootDir: string, rootDir: string,
store: TaskStore,
): Promise<AgentResult> { ): Promise<AgentResult> {
await ensureEngineReady(); await ensureEngineReady();
@@ -724,6 +736,7 @@ async function createTargetInterviewAgent(
cwd: rootDir, cwd: rootDir,
systemPrompt: getSystemPrompt(session.targetType), systemPrompt: getSystemPrompt(session.targetType),
tools: "readonly", tools: "readonly",
customTools: [...createPlanningBoardTools(store)],
onThinking: (delta: string) => { onThinking: (delta: string) => {
session.thinkingOutput += delta; session.thinkingOutput += delta;
persistThinking(session.id, session.thinkingOutput); persistThinking(session.id, session.thinkingOutput);
@@ -771,6 +784,7 @@ function formatInterviewHistory(
async function ensureInterviewAgent( async function ensureInterviewAgent(
session: TargetInterviewSession, session: TargetInterviewSession,
rootDir: string | undefined, rootDir: string | undefined,
store: TaskStore | undefined,
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>, historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
): Promise<void> { ): Promise<void> {
if (session.agent) { if (session.agent) {
@@ -783,7 +797,13 @@ async function ensureInterviewAgent(
); );
} }
session.agent = await createTargetInterviewAgent(session, rootDir); if (!store) {
throw new TargetInvalidSessionStateError(
"AI agent not available for this session and cannot be resumed without task store context",
);
}
session.agent = await createTargetInterviewAgent(session, rootDir, store);
if (historyForReplay.length === 0) { if (historyForReplay.length === 0) {
return; return;
@@ -820,9 +840,9 @@ async function ensureInterviewAgent(
/** /**
* Initialize the AI agent for a session and start the first turn. * Initialize the AI agent for a session and start the first turn.
*/ */
async function initializeAgent(session: TargetInterviewSession, rootDir: string): Promise<void> { async function initializeAgent(session: TargetInterviewSession, rootDir: string, store: TaskStore): Promise<void> {
try { try {
session.agent = await createTargetInterviewAgent(session, rootDir); session.agent = await createTargetInterviewAgent(session, rootDir, store);
session.updatedAt = new Date(); session.updatedAt = new Date();
// Send initial message to get first question // Send initial message to get first question
@@ -1002,7 +1022,8 @@ export async function createTargetInterviewSession(
targetId: string, targetId: string,
targetTitle: string, targetTitle: string,
missionContext: string | undefined, missionContext: string | undefined,
rootDir: string rootDir: string,
store: TaskStore,
): Promise<string> { ): Promise<string> {
if (!checkRateLimit(ip)) { if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip); const resetTime = getRateLimitResetTime(ip);
@@ -1032,7 +1053,7 @@ export async function createTargetInterviewSession(
persistSession(session, "generating"); persistSession(session, "generating");
// Initialize AI agent in background // Initialize AI agent in background
initializeAgent(session, rootDir).catch((err) => { initializeAgent(session, rootDir, store).catch((err) => {
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", err.message || "Failed to initialize AI agent"); persistSession(session, "error", err.message || "Failed to initialize AI agent");
milestoneSliceInterviewStreamManager.broadcast(sessionId, { milestoneSliceInterviewStreamManager.broadcast(sessionId, {
@@ -1051,6 +1072,7 @@ export async function submitTargetInterviewResponse(
sessionId: string, sessionId: string,
responses: Record<string, unknown>, responses: Record<string, unknown>,
rootDir?: string, rootDir?: string,
store?: TaskStore,
): Promise<TargetInterviewResponse> { ): Promise<TargetInterviewResponse> {
const session = getTargetInterviewSession(sessionId); const session = getTargetInterviewSession(sessionId);
if (!session) { if (!session) {
@@ -1072,7 +1094,7 @@ export async function submitTargetInterviewResponse(
if (!session.agent) { if (!session.agent) {
const replayHistory = session.history.slice(0, -1); const replayHistory = session.history.slice(0, -1);
await ensureInterviewAgent(session, rootDir, replayHistory); await ensureInterviewAgent(session, rootDir, store, replayHistory);
} }
const message = formatResponseForAgent(session.currentQuestion, responses); const message = formatResponseForAgent(session.currentQuestion, responses);
@@ -1099,7 +1121,7 @@ export async function submitTargetInterviewResponse(
/** /**
* Retry a failed interview session. * Retry a failed interview session.
*/ */
export async function retryTargetInterviewSession(sessionId: string, rootDir: string): Promise<void> { export async function retryTargetInterviewSession(sessionId: string, rootDir: string, store?: TaskStore): Promise<void> {
const session = getTargetInterviewSession(sessionId); const session = getTargetInterviewSession(sessionId);
if (!session) { if (!session) {
throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`); throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`);
@@ -1126,7 +1148,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st
persistSession(session, "generating"); persistSession(session, "generating");
if (session.history.length === 0) { if (session.history.length === 0) {
await ensureInterviewAgent(session, rootDir, []); await ensureInterviewAgent(session, rootDir, store, []);
await continueAgentConversation( await continueAgentConversation(
session, session,
`I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` + `I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` +
@@ -1139,7 +1161,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st
const replayHistory = session.history.slice(0, -1); const replayHistory = session.history.slice(0, -1);
const lastEntry = session.history[session.history.length - 1]; const lastEntry = session.history[session.history.length - 1];
await ensureInterviewAgent(session, rootDir, replayHistory); await ensureInterviewAgent(session, rootDir, store, replayHistory);
const replayMessage = formatResponseForAgent( const replayMessage = formatResponseForAgent(
lastEntry.question, lastEntry.question,
coerceResponseRecord(lastEntry.question, lastEntry.response), coerceResponseRecord(lastEntry.question, lastEntry.response),

View File

@@ -2930,7 +2930,8 @@ export function createMissionRouter(
try { try {
const ip = req.ip || req.socket.remoteAddress || "unknown"; const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
// Get mission context for the interview // Get mission context for the interview
const mission = missionStore.getMission(milestone.missionId); const mission = missionStore.getMission(milestone.missionId);
@@ -2946,7 +2947,8 @@ export function createMissionRouter(
milestoneId, milestoneId,
milestone.title, milestone.title,
missionContext, missionContext,
rootDir rootDir,
scopedStore,
); );
res.status(201).json({ sessionId }); res.status(201).json({ sessionId });
} catch (err: unknown) { } catch (err: unknown) {
@@ -2998,8 +3000,9 @@ export function createMissionRouter(
try { try {
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js"); const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir); const rootDir = scopedStore.getRootDir();
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore);
res.json(result); res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
const errName = err instanceof Error ? err.name : ""; const errName = err instanceof Error ? err.name : "";
@@ -3161,8 +3164,9 @@ export function createMissionRouter(
try { try {
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js"); const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
await retryTargetInterviewSession(sessionId, rootDir); const rootDir = scopedStore.getRootDir();
await retryTargetInterviewSession(sessionId, rootDir, scopedStore);
res.json({ success: true, sessionId }); res.json({ success: true, sessionId });
} catch (err: unknown) { } catch (err: unknown) {
const errName = err instanceof Error ? err.name : ""; const errName = err instanceof Error ? err.name : "";
@@ -3271,7 +3275,8 @@ export function createMissionRouter(
try { try {
const ip = req.ip || req.socket.remoteAddress || "unknown"; const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
// Get mission hierarchy context for the interview // Get mission hierarchy context for the interview
const milestone = missionStore.getMilestone(slice.milestoneId); const milestone = missionStore.getMilestone(slice.milestoneId);
@@ -3290,7 +3295,8 @@ export function createMissionRouter(
sliceId, sliceId,
slice.title, slice.title,
missionContext, missionContext,
rootDir rootDir,
scopedStore,
); );
res.status(201).json({ sessionId }); res.status(201).json({ sessionId });
} catch (err: unknown) { } catch (err: unknown) {
@@ -3342,8 +3348,9 @@ export function createMissionRouter(
try { try {
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js"); const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir); const rootDir = scopedStore.getRootDir();
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore);
res.json(result); res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
const errName = err instanceof Error ? err.name : ""; const errName = err instanceof Error ? err.name : "";
@@ -3505,8 +3512,9 @@ export function createMissionRouter(
try { try {
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js"); const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
const rootDir = await getRootDirForRequest(req); const { store: scopedStore } = await getProjectContext(req);
await retryTargetInterviewSession(sessionId, rootDir); const rootDir = scopedStore.getRootDir();
await retryTargetInterviewSession(sessionId, rootDir, scopedStore);
res.json({ success: true, sessionId }); res.json({ success: true, sessionId });
} catch (err: unknown) { } catch (err: unknown) {
const errName = err instanceof Error ? err.name : ""; const errName = err instanceof Error ? err.name : "";