Make the AI mission planning workspace movable on desktop and safer when streams fail. - Host the Plan Mission with AI modal in FloatingWindow with desktop drag/resize geometry and mobile full-screen preservation. - Normalize terminal mission interview stream failures, close SSE/keepalive once, and suppress duplicate late terminal events. - Cover modal geometry and stream-error behavior with dashboard tests and document the operator-facing behavior. Files changed: .changeset/fn-6975-mission-modal-stream.md | 7 ++ docs/dashboard-guide.md | 7 ++ .../api/__tests__/mission-interview-stream.test.ts | 98 ++++++++++++++++++ packages/dashboard/app/api/legacy.ts | 66 ++++++++++--- .../app/components/MissionInterviewModal.css | 51 ++++++++++ .../app/components/MissionInterviewModal.tsx | 37 ++++--- .../__tests__/MissionInterviewModal.test.tsx | 110 ++++++++++++++++++++- 7 files changed, 344 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-6975 Fusion-Task-Lineage: b2ffa558-8b7e-4a46-8882-f2c4f6189831
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { connectMissionInterviewStream } from "../legacy";
|
|
|
|
class MockEventSource {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSED = 2;
|
|
static instances: MockEventSource[] = [];
|
|
|
|
url: string;
|
|
readyState = MockEventSource.OPEN;
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
close = vi.fn(() => {
|
|
this.readyState = MockEventSource.CLOSED;
|
|
});
|
|
|
|
private listeners = new Map<string, Array<(event: MessageEvent) => void>>();
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
MockEventSource.instances.push(this);
|
|
}
|
|
|
|
addEventListener(eventName: string, listener: EventListener) {
|
|
const listeners = this.listeners.get(eventName) ?? [];
|
|
listeners.push(listener as (event: MessageEvent) => void);
|
|
this.listeners.set(eventName, listeners);
|
|
}
|
|
|
|
dispatch(eventName: string, data = "", lastEventId = "") {
|
|
const event = { data, lastEventId } as MessageEvent;
|
|
for (const listener of this.listeners.get(eventName) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("connectMissionInterviewStream", () => {
|
|
beforeEach(() => {
|
|
MockEventSource.instances = [];
|
|
vi.stubGlobal("EventSource", MockEventSource);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function connect(handlers: Partial<Parameters<typeof connectMissionInterviewStream>[2]> = {}, options?: { maxReconnectAttempts?: number }) {
|
|
const onError = vi.fn();
|
|
const onComplete = vi.fn();
|
|
const connection = connectMissionInterviewStream("mission-session-1", undefined, { onError, onComplete, ...handlers }, options);
|
|
const source = MockEventSource.instances[0];
|
|
return { connection, source, onError, onComplete };
|
|
}
|
|
|
|
it.each([
|
|
["JSON message", JSON.stringify({ message: "The model rejected the prompt." }), "The model rejected the prompt."],
|
|
["JSON error fallback", JSON.stringify({ error: "Provider is unavailable." }), "Provider is unavailable."],
|
|
["JSON string", JSON.stringify("Please try again later."), "Please try again later."],
|
|
["non-JSON text", "Temporary outage", "Temporary outage"],
|
|
["empty data", "", "The mission interview stream was interrupted. Please retry the session."],
|
|
["generic stream error", "Stream error", "The mission interview stream was interrupted. Please retry the session."],
|
|
["JSON primitive", JSON.stringify(500), "The mission interview stream was interrupted. Please retry the session."],
|
|
])("normalizes terminal error payloads: %s", (_name, data, expected) => {
|
|
const { source, onError } = connect();
|
|
|
|
source.dispatch("error", data);
|
|
|
|
expect(onError).toHaveBeenCalledWith(expected);
|
|
expect(source.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dedupes late terminal events and closes the stale EventSource once", () => {
|
|
const { source, onError, onComplete } = connect();
|
|
|
|
source.dispatch("error", JSON.stringify({ message: "First failure" }), "1");
|
|
source.dispatch("error", JSON.stringify({ message: "Second failure" }), "2");
|
|
source.dispatch("complete", "", "3");
|
|
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
expect(onError).toHaveBeenCalledWith("First failure");
|
|
expect(onComplete).not.toHaveBeenCalled();
|
|
expect(source.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports fatal reconnect exhaustion through the same recoverable error path", () => {
|
|
const { source, onError } = connect({}, { maxReconnectAttempts: 0 });
|
|
|
|
source.readyState = MockEventSource.CLOSED;
|
|
source.onerror?.();
|
|
|
|
expect(onError).toHaveBeenCalledWith("Connection lost");
|
|
expect(source.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|