fix: serialize session lifecycle transitions
This commit is contained in:
@@ -20,6 +20,12 @@ const LIST = [
|
||||
const resumeResult = (id: string, alreadyActive = false) =>
|
||||
HttpResponse.json({ id, alreadyActive });
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
@@ -134,58 +140,104 @@ test("an already-active same-session Resume preserves its EventSource and store"
|
||||
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||
});
|
||||
|
||||
test("a false then true pair of concurrent Resume completions preserves the cold stream", async () => {
|
||||
test("concurrent same-id Resume invocations share one cold request and replacement source", async () => {
|
||||
let resumeCalls = 0;
|
||||
let releaseCold!: () => void;
|
||||
let releaseAlreadyActive!: () => void;
|
||||
let markColdStarted!: () => void;
|
||||
let markAlreadyActiveStarted!: () => void;
|
||||
const coldStarted = new Promise<void>((resolve) => { markColdStarted = resolve; });
|
||||
const alreadyActiveStarted = new Promise<void>((resolve) => { markAlreadyActiveStarted = resolve; });
|
||||
const coldReleased = new Promise<void>((resolve) => { releaseCold = resolve; });
|
||||
const alreadyActiveReleased = new Promise<void>((resolve) => { releaseAlreadyActive = resolve; });
|
||||
const coldGate = deferred();
|
||||
const coldStarted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
resumeCalls += 1;
|
||||
if (resumeCalls === 1) {
|
||||
markColdStarted();
|
||||
await coldReleased;
|
||||
return resumeResult("s1", false);
|
||||
}
|
||||
markAlreadyActiveStarted();
|
||||
await alreadyActiveReleased;
|
||||
return resumeResult("s1", true);
|
||||
if (resumeCalls === 1) return resumeResult("s1", false);
|
||||
if (resumeCalls === 2) coldStarted.resolve();
|
||||
await coldGate.promise;
|
||||
return resumeResult("s1", false);
|
||||
}),
|
||||
http.get("http://localhost:8787/sessions/:id", () =>
|
||||
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||
);
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
const resumeButton = await screen.findByRole("button", { name: /resume/i });
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
const oldSource = FakeEventSource.instances[0];
|
||||
act(() => oldSource.emitNamed("info", { type: "info", text: "Old generation" }, "900"));
|
||||
|
||||
await userEvent.click(screen.getByText("Attiva uno"));
|
||||
const resumeButton = await screen.findByRole("button", { name: /resume/i });
|
||||
await userEvent.click(resumeButton);
|
||||
await coldStarted;
|
||||
await coldStarted.promise;
|
||||
await userEvent.click(resumeButton);
|
||||
await alreadyActiveStarted;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(resumeCalls).toBe(2);
|
||||
|
||||
releaseCold();
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
const stream = FakeEventSource.instances[0];
|
||||
act(() => stream.emitNamed(
|
||||
coldGate.resolve();
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||
const replacement = FakeEventSource.instances[1];
|
||||
expect(oldSource.closed).toBe(true);
|
||||
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
||||
expect(useSessionStore.getState().activityLog).toEqual([
|
||||
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
||||
]);
|
||||
act(() => replacement.emitNamed(
|
||||
"ui_request",
|
||||
{ type: "ui_request", ui_request: { id: "cold-gate", widget: "select" } },
|
||||
"1",
|
||||
));
|
||||
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||
|
||||
releaseAlreadyActive();
|
||||
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
|
||||
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
expect(stream.closed).toBe(false);
|
||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("cold-gate");
|
||||
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||
});
|
||||
|
||||
test("a committed Resume releases same-id single-flight before its manifest settles", async () => {
|
||||
const other = {
|
||||
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
};
|
||||
let s1ResumeCalls = 0;
|
||||
let s1ManifestCalls = 0;
|
||||
const firstS1ManifestGate = deferred();
|
||||
const firstS1ManifestStarted = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
||||
const id = params.id as string;
|
||||
if (id === "s1") s1ResumeCalls += 1;
|
||||
return resumeResult(id);
|
||||
}),
|
||||
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
||||
if (params.id === "s1") {
|
||||
s1ManifestCalls += 1;
|
||||
if (s1ManifestCalls === 1) {
|
||||
firstS1ManifestStarted.resolve();
|
||||
await firstS1ManifestGate.promise;
|
||||
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
|
||||
}
|
||||
return HttpResponse.json({ id: "s1", status: "open", phase: 4 });
|
||||
}
|
||||
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
|
||||
}),
|
||||
);
|
||||
wrap();
|
||||
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await firstS1ManifestStarted.promise;
|
||||
expect(s1ResumeCalls).toBe(1);
|
||||
|
||||
await userEvent.click(screen.getByText("Attiva tre"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
||||
|
||||
await userEvent.click(screen.getByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await waitFor(() => expect(s1ResumeCalls).toBe(2));
|
||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
|
||||
|
||||
firstS1ManifestGate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F4");
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
|
||||
});
|
||||
|
||||
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
||||
@@ -321,6 +373,122 @@ test("resuming a different already-active session binds it only after success",
|
||||
}));
|
||||
});
|
||||
|
||||
test("competing Resume requests for different ids commit only the latest intent", async () => {
|
||||
const other = {
|
||||
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
};
|
||||
const s1Gate = deferred();
|
||||
const s3Gate = deferred();
|
||||
const s1Started = deferred();
|
||||
const s3Started = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
||||
const id = params.id as string;
|
||||
if (id === "s1") {
|
||||
s1Started.resolve();
|
||||
await s1Gate.promise;
|
||||
} else {
|
||||
s3Started.resolve();
|
||||
await s3Gate.promise;
|
||||
}
|
||||
return resumeResult(id);
|
||||
}),
|
||||
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
||||
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
||||
);
|
||||
wrap();
|
||||
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await s1Started.promise;
|
||||
await userEvent.click(screen.getByText("Attiva tre"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await s3Started.promise;
|
||||
|
||||
s3Gate.resolve();
|
||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
const latestSource = FakeEventSource.instances[0];
|
||||
expect(latestSource.url).toContain("/sessions/s3/events");
|
||||
act(() => latestSource.emitNamed("info", { type: "info", text: "Latest target" }, "1"));
|
||||
|
||||
s1Gate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
expect(latestSource.closed).toBe(false);
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F3");
|
||||
expect(useSessionStore.getState().activityLog).toContainEqual(expect.objectContaining({
|
||||
text: "Latest target",
|
||||
}));
|
||||
});
|
||||
|
||||
test("a stale Resume manifest cannot repaint the latest session phase", async () => {
|
||||
const other = {
|
||||
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
||||
created_at: "2026-01-03T00:00:00Z",
|
||||
};
|
||||
const s1ManifestGate = deferred();
|
||||
const s1ManifestStarted = deferred();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
||||
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
||||
resumeResult(params.id as string)),
|
||||
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
||||
if (params.id === "s1") {
|
||||
s1ManifestStarted.resolve();
|
||||
await s1ManifestGate.promise;
|
||||
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
|
||||
}
|
||||
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
|
||||
}),
|
||||
);
|
||||
wrap();
|
||||
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await s1ManifestStarted.promise;
|
||||
await userEvent.click(screen.getByText("Attiva tre"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
|
||||
|
||||
s1ManifestGate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F3");
|
||||
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
|
||||
});
|
||||
|
||||
test("starting a new question invalidates a pending Resume intent", async () => {
|
||||
const resumeGate = deferred();
|
||||
const resumeStarted = deferred();
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||
resumeStarted.resolve();
|
||||
await resumeGate.promise;
|
||||
return resumeResult("s1");
|
||||
}),
|
||||
http.post("http://localhost:8787/runtime/prewarm", () =>
|
||||
HttpResponse.json({ status: "warming" }, { status: 202 })),
|
||||
);
|
||||
wrap();
|
||||
|
||||
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||
await resumeStarted.promise;
|
||||
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
|
||||
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
|
||||
|
||||
resumeGate.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a failed Resume with no active session preserves the panel and existing activity", async () => {
|
||||
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
|
||||
useSessionStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user