feat(FN-1453): fix mission autopilot progression and state refresh

- Fix mission autopilot slice activation when tasks complete
- Fix state refresh in MissionManager to properly reflect autopilot status
- Fix scheduler to correctly trigger autopilot progression events
- Add unit tests for mission store autopilot state transitions
- Add component tests for MissionManager autopilot toggle and state display
- Add e2e tests for mission autopilot lifecycle (enable → task completion → slice progression)
- Update documentation with autopilot state machine details
This commit is contained in:
gsxdsm
2026-04-09 16:12:58 -07:00
parent c38e254258
commit 8ba375549c
9 changed files with 456 additions and 13 deletions

View File

@@ -576,6 +576,22 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
void loadMissionHealth(missions);
};
const handleMissionUpdated = (rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated mission state (autopilot, status, etc.)
if (selectedMission) {
void loadMissionDetail(selectedMission.id);
}
};
const handleSliceUpdated = (rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated slice status
if (selectedMission) {
void loadMissionDetail(selectedMission.id);
}
};
const handleFeatureUpdated = () => {
refreshHealth();
// Reload the selected mission detail to reflect updated feature status
@@ -628,14 +644,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
};
eventSource.addEventListener("mission:updated", refreshHealth);
eventSource.addEventListener("slice:updated", refreshHealth);
eventSource.addEventListener("mission:updated", handleMissionUpdated);
eventSource.addEventListener("slice:updated", handleSliceUpdated);
eventSource.addEventListener("feature:updated", handleFeatureUpdated);
eventSource.addEventListener("mission:event", handleMissionEvent);
return () => {
eventSource.removeEventListener("mission:updated", refreshHealth);
eventSource.removeEventListener("slice:updated", refreshHealth);
eventSource.removeEventListener("mission:updated", handleMissionUpdated);
eventSource.removeEventListener("slice:updated", handleSliceUpdated);
eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
eventSource.removeEventListener("mission:event", handleMissionEvent);
eventSource.close();

View File

@@ -825,6 +825,97 @@ describe("MissionManager", () => {
});
});
it("reloads selected mission detail when mission:updated SSE event arrives", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
// Click on the mission to open detail view
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
// Record initial fetch calls for mission detail
const initialFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(initialFetchCount).toBeGreaterThan(0);
// Emit a mission:updated SSE event for the selected mission
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("mission:updated", {
id: "M-001",
title: "Build Auth System",
status: "active",
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: new Date().toISOString(),
});
}
});
// Verify mission detail was reloaded (fetch was called again for the mission)
await waitFor(() => {
const updatedFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(updatedFetchCount).toBeGreaterThan(initialFetchCount);
});
});
it("reloads selected mission detail when slice:updated SSE event arrives", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
// Click on the mission to open detail view
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
// Record initial fetch calls for mission detail
const initialFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(initialFetchCount).toBeGreaterThan(0);
// Emit a slice:updated SSE event for a slice in the selected mission
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("slice:updated", {
id: "SL-001",
milestoneId: "MS-001",
status: "active",
});
}
});
// Verify mission detail was reloaded (fetch was called again for the mission)
await waitFor(() => {
const updatedFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(updatedFetchCount).toBeGreaterThan(initialFetchCount);
});
});
it("shows empty state when no missions exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);