feat(FN-1369): add mission planning context propagation to tasks
- Add buildEnrichedDescription() for hierarchical mission context during triage - Propagate mission → milestone → slice → feature context to task descriptions - Add planningNotes and verification fields to milestones and slices - Track planState on slices (not_started, planned, needs_update) - Add apply/skip interview endpoints for milestone and slice planning - Fix error mapping for rate limit and apply interview routes - Add comprehensive integration tests for context enrichment - Add AGENTS.md documentation for Mission Planning Context feature
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
getTargetInterviewSummary,
|
||||
getRateLimitResetTime,
|
||||
InvalidSessionStateError,
|
||||
TargetInvalidSessionStateError,
|
||||
milestoneSliceInterviewStreamManager,
|
||||
parseTargetInterviewResponse,
|
||||
rehydrateFromStore,
|
||||
@@ -462,7 +463,7 @@ describe("milestone-slice-interview module", () => {
|
||||
|
||||
describe("applyTargetInterview", () => {
|
||||
// Note: Full end-to-end tests with AI completion are complex due to async agent mocking.
|
||||
// These tests verify error handling.
|
||||
// These tests verify error handling and integration with MissionStore.
|
||||
|
||||
it("throws when session not found", () => {
|
||||
const mockMissionStore = {} as any;
|
||||
@@ -470,6 +471,151 @@ describe("milestone-slice-interview module", () => {
|
||||
TargetSessionNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it("persists milestone planning notes and verification to store", async () => {
|
||||
// Create a session and set its summary
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-apply",
|
||||
"Apply Test",
|
||||
"Mission context",
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
// Set the session's summary directly to simulate completed interview
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (session) {
|
||||
(session as any).summary = {
|
||||
description: "Refined milestone description",
|
||||
planningNotes: "Key decisions: JWT tokens, refresh token support",
|
||||
verification: "All auth flows work correctly",
|
||||
};
|
||||
}
|
||||
|
||||
// Mock MissionStore
|
||||
const mockUpdateMilestone = vi.fn().mockReturnValue({ id: "ms-apply" });
|
||||
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-apply", title: "Apply Test" });
|
||||
const mockMissionStore = {
|
||||
getMilestone: mockGetMilestone,
|
||||
updateMilestone: mockUpdateMilestone,
|
||||
} as any;
|
||||
|
||||
// Apply the interview results
|
||||
const result = applyTargetInterview(sessionId, mockMissionStore);
|
||||
|
||||
// Verify update was called with correct fields
|
||||
expect(mockUpdateMilestone).toHaveBeenCalledWith("ms-apply", expect.objectContaining({
|
||||
description: "Refined milestone description",
|
||||
planningNotes: "Key decisions: JWT tokens, refresh token support",
|
||||
verification: "All auth flows work correctly",
|
||||
interviewState: "completed",
|
||||
}));
|
||||
});
|
||||
|
||||
it("persists slice planning notes and planState to store", async () => {
|
||||
// Create a slice session
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"slice",
|
||||
"sl-apply",
|
||||
"Apply Slice Test",
|
||||
"Mission | Milestone context",
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
// Set the session's summary directly
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (session) {
|
||||
(session as any).summary = {
|
||||
description: "Refined slice description",
|
||||
planningNotes: "Slice decisions: React Hook Form, Zod validation",
|
||||
verification: "All form validations pass",
|
||||
};
|
||||
}
|
||||
|
||||
// Mock MissionStore
|
||||
const mockUpdateSlice = vi.fn().mockReturnValue({ id: "sl-apply" });
|
||||
const mockGetSlice = vi.fn().mockReturnValue({ id: "sl-apply", title: "Apply Slice Test" });
|
||||
const mockMissionStore = {
|
||||
getSlice: mockGetSlice,
|
||||
updateSlice: mockUpdateSlice,
|
||||
} as any;
|
||||
|
||||
// Apply the interview results
|
||||
const result = applyTargetInterview(sessionId, mockMissionStore);
|
||||
|
||||
// Verify update was called with correct fields
|
||||
expect(mockUpdateSlice).toHaveBeenCalledWith("sl-apply", expect.objectContaining({
|
||||
description: "Refined slice description",
|
||||
planningNotes: "Slice decisions: React Hook Form, Zod validation",
|
||||
verification: "All form validations pass",
|
||||
planState: "planned",
|
||||
}));
|
||||
});
|
||||
|
||||
it("cleans up session after persisting", async () => {
|
||||
// Create a milestone session
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-cleanup",
|
||||
"Cleanup Test",
|
||||
"Context",
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
// Set the session's summary
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
if (session) {
|
||||
(session as any).summary = {
|
||||
description: "Desc",
|
||||
planningNotes: "Notes",
|
||||
verification: "Verify",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify session exists before apply
|
||||
expect(getTargetInterviewSession(sessionId)).toBeDefined();
|
||||
|
||||
// Mock MissionStore
|
||||
const mockUpdateMilestone = vi.fn().mockReturnValue({ id: "ms-cleanup" });
|
||||
const mockGetMilestone = vi.fn().mockReturnValue({ id: "ms-cleanup" });
|
||||
const mockMissionStore = {
|
||||
getMilestone: mockGetMilestone,
|
||||
updateMilestone: mockUpdateMilestone,
|
||||
} as any;
|
||||
|
||||
// Apply the interview results
|
||||
applyTargetInterview(sessionId, mockMissionStore);
|
||||
|
||||
// Verify session was cleaned up
|
||||
expect(getTargetInterviewSession(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when session has no summary to apply", async () => {
|
||||
// Create a session without setting summary
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"127.0.0.1",
|
||||
"milestone",
|
||||
"ms-no-summary",
|
||||
"No Summary",
|
||||
"Context",
|
||||
"/tmp/project"
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
const mockMissionStore = {
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "ms-no-summary" }),
|
||||
} as any;
|
||||
|
||||
expect(() => applyTargetInterview(sessionId, mockMissionStore)).toThrow(
|
||||
TargetInvalidSessionStateError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipTargetInterview", () => {
|
||||
|
||||
@@ -3545,4 +3545,127 @@ describe("Mission API", () => {
|
||||
expect(skipSpy).toHaveBeenCalledWith("slice", slice.id, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ── Interview Error Mapping Tests ──────────────────────────────────────────
|
||||
|
||||
describe("interview error mapping", () => {
|
||||
it("POST milestone interview/respond returns 404 for unknown session", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||
throw new TargetSessionNotFoundError("Session not found");
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/MS-TEST1/interview/respond",
|
||||
JSON.stringify({ sessionId: "nonexistent-session", responses: {} }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST slice interview/respond returns 404 for unknown session", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "submitTargetInterviewResponse").mockImplementation(async () => {
|
||||
const { TargetSessionNotFoundError } = await import("./milestone-slice-interview.js");
|
||||
throw new TargetSessionNotFoundError("Session not found");
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/slices/SL-TEST1/interview/respond",
|
||||
JSON.stringify({ sessionId: "nonexistent-session", responses: {} }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST milestone interview/start returns 429 when rate limited", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Rate Limit Test" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||
throw new RateLimitError("Rate limit exceeded", new Date(Date.now() + 3600000));
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("POST slice interview/start returns 429 when rate limited", async () => {
|
||||
const { app, missionStore } = buildApp({});
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Rate Limit Test" });
|
||||
const milestone = ms.addMilestone(mission.id, { title: "Rate Limit Milestone" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Rate Limit Slice" });
|
||||
|
||||
const importMock = await import("./milestone-slice-interview.js");
|
||||
vi.spyOn(importMock, "createTargetInterviewSession").mockImplementation(async () => {
|
||||
const { RateLimitError } = await import("./milestone-slice-interview.js");
|
||||
throw new RateLimitError("Rate limit exceeded");
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/slices/${slice.id}/interview/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("POST milestone interview/skip returns 404 for nonexistent milestone", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/milestones/MS-NONEXISTENT/interview/skip",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST slice interview/skip returns 404 for nonexistent slice", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/slices/SL-NONEXISTENT/interview/skip",
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user