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:
gsxdsm
2026-04-10 10:44:03 -07:00
parent 583285fa65
commit 4cb378477c
5 changed files with 1077 additions and 11 deletions

View File

@@ -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", () => {

View File

@@ -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);
});
});
});