fix: serialize session lifecycle transitions

This commit is contained in:
User
2026-07-15 01:37:42 +02:00
parent 08b1f4909e
commit 6747f6f8a0
10 changed files with 1402 additions and 101 deletions
+199 -31
View File
@@ -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({