fix: harden resume and SSE replay

This commit is contained in:
User
2026-07-15 00:06:00 +02:00
parent e5366d14a6
commit 2b4797f133
18 changed files with 871 additions and 102 deletions
@@ -17,9 +17,13 @@ const LIST = [
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
];
const resumeResult = (id: string, alreadyActive = false) =>
HttpResponse.json({ id, alreadyActive });
beforeEach(() => {
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
useSessionStore.getState().resetSession();
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
@@ -74,7 +78,7 @@ test("Resume from the panel activates the session and closes the panel", async (
server.use(
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
resumed = params.id as string;
return new HttpResponse(null, { status: 204 });
return resumeResult(resumed);
}),
);
wrap();
@@ -88,10 +92,10 @@ test("Resume from the panel activates the session and closes the panel", async (
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
});
test("Resume paints the re-entry phase from the manifest (optimistic, before the first gate)", async () => {
test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => {
useSessionStore.getState().resetSession();
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
);
@@ -102,10 +106,13 @@ test("Resume paints the re-entry phase from the manifest (optimistic, before the
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
});
test("resuming the active session reconnects its EventSource", async () => {
test("an already-active same-session Resume preserves its EventSource and store", async () => {
let resumeCalls = 0;
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 204 })),
http.post("http://localhost:8787/sessions/:id/resume", () => {
resumeCalls += 1;
return resumeResult("s1", resumeCalls > 1);
}),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
);
@@ -114,18 +121,23 @@ test("resuming the active session reconnects its EventSource", async () => {
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const first = FakeEventSource.instances[0];
act(() => first.emitNamed("info", { type: "info", text: "Live state" }, "4"));
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
await userEvent.click(screen.getByText("Attiva uno"));
await screen.findByText("Domanda originale");
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(first.closed).toBe(true);
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
expect(FakeEventSource.instances).toHaveLength(1);
expect(first.closed).toBe(false);
expect(useSessionStore.getState().activityLog).toEqual(before);
});
test("same-session Resume reconnects only after the deferred POST succeeds", async () => {
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 204 })),
resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
);
@@ -134,6 +146,7 @@ test("same-session Resume reconnects only after the deferred POST succeeds", asy
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const first = FakeEventSource.instances[0];
act(() => first.emitNamed("info", { type: "info", text: "Before cold Resume" }, "7"));
let releaseResume!: () => void;
let markStarted!: () => void;
@@ -142,24 +155,62 @@ test("same-session Resume reconnects only after the deferred POST succeeds", asy
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => {
markStarted();
await resumeReleased;
return new HttpResponse(null, { status: 204 });
return resumeResult("s1");
}));
await userEvent.click(screen.getByText("Attiva uno"));
await screen.findByText("Domanda originale");
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await resumeStarted;
expect(FakeEventSource.instances).toHaveLength(1);
expect(first.closed).toBe(false);
expect(screen.getByText("Domanda originale")).toBeInTheDocument();
expect(useSessionStore.getState().activityLog).toContainEqual({
kind: "status", phase: "F1", text: "Before cold Resume", level: "info",
});
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "Still attached" }, "8"));
expect(useSessionStore.getState().transcript).toEqual([
{ role: "assistant", text: "Still attached" },
]);
releaseResume();
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
expect(first.closed).toBe(true);
const replacement = FakeEventSource.instances[1];
expect(replacement.url).toContain("/sessions/s1/events?lastEventId=8");
expect(useSessionStore.getState().transcript).toEqual([]);
expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "lifecycle", phase: null, text: "Resuming session" },
]);
act(() => replacement.emitNamed(
"text_delta",
{ type: "text_delta", text: "Post-resume delivery" },
"9",
));
expect(useSessionStore.getState().transcript).toEqual([
{ role: "assistant", text: "Post-resume delivery" },
]);
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "assistant"))
.toEqual([{ kind: "assistant", phase: "F1", text: "Post-resume delivery" }]);
const gate = {
type: "ui_request" as const,
ui_request: { id: "post-resume-gate", widget: "select", title: "Review once" },
};
act(() => {
replacement.emitNamed("ui_request", gate, "10");
replacement.emitNamed("ui_request", gate, "11");
});
expect(useSessionStore.getState().pendingWidget?.id).toBe("post-resume-gate");
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "gate"))
.toEqual([{ kind: "gate", phase: "F1", text: "Review once" }]);
});
test("a failed same-session Resume does not create a replacement EventSource", async () => {
test("a failed same-session Resume preserves its source, activity, and document panel", async () => {
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 204 })),
resumeResult("s1")),
http.get("http://localhost:8787/sessions/:id", () =>
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
);
@@ -167,6 +218,9 @@ test("a failed same-session Resume does not create a replacement EventSource", a
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const first = FakeEventSource.instances[0];
act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4"));
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 409 })));
@@ -175,17 +229,21 @@ test("a failed same-session Resume does not create a replacement EventSource", a
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
expect(FakeEventSource.instances).toHaveLength(1);
expect(first.closed).toBe(false);
expect(useSessionStore.getState().activityLog).toEqual(before);
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "After failure" }, "5"));
expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("After failure");
});
test("resuming a different session opens exactly one new EventSource", async () => {
test("resuming a different already-active session binds it only after success", async () => {
const other = {
...LIST[0], id: "s3", question: "Attiva tre", group: null,
created_at: "2026-01-03T00:00:00Z",
};
server.use(
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
http.post("http://localhost:8787/sessions/:id/resume", () =>
new HttpResponse(null, { status: 204 })),
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
resumeResult(params.id as string, params.id === "s3")),
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
);
@@ -193,28 +251,42 @@ test("resuming a different session opens exactly one new EventSource", async ()
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
const first = FakeEventSource.instances[0];
act(() => first.emitNamed("info", { type: "info", text: "Old target" }, "3"));
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).toHaveLength(2);
expect(first.closed).toBe(true);
expect(FakeEventSource.instances[1].url).toContain("/sessions/s3/events");
expect(FakeEventSource.instances[1].url).not.toContain("lastEventId");
expect(useSessionStore.getState().activityLog).not.toContainEqual(expect.objectContaining({
text: "Old target",
}));
});
test("a failed resume keeps the panel open and does not activate the session", async () => {
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({
activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }],
});
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await screen.findByText("Domanda originale"); // panel open
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
// panel stays open (document still visible) after the failed resume
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
expect(FakeEventSource.instances).toHaveLength(0);
expect(useSessionStore.getState().activityLog).toEqual([
{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" },
]);
});
test("closing and reopening Model activity preserves the complete activity log", async () => {
wrap();
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })));
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
act(() => {
@@ -257,7 +329,7 @@ test("session finalization shows the completion banner and returns to landing",
useSessionStore.getState().resetSession();
let finalized = false;
server.use(
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
http.get("http://localhost:8787/sessions", () =>
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
);
@@ -299,7 +371,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })));
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
wrap();
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
+21 -18
View File
@@ -100,22 +100,29 @@ export function AppShell() {
});
}
async function doResume(id: string) {
const s = sessions.find((x) => x.id === id) ?? null;
const reconnectSameSession = activeSessionId === id;
// Optimistic switch: change to the session view IMMEDIATELY so the click feels
// instant (the resume POST spawns a Pi process and can take seconds). The
// working spinner shows straight away; the backend calls run after.
setPanelSession(null);
resetSession();
recordLifecycle("Resuming session");
setAwaitingQuestion(false);
// Optimistic: the resume POST is about to hand the ball to the harness.
setAgentActive(true);
setActiveSessionId(id);
try {
await resumeSession(id);
if (reconnectSameSession) setStreamGeneration((value) => value + 1);
// Optimistic phase paint: colour the re-entry phase before the first gate.
const result = await resumeSession(id);
setPanelSession(null);
setAwaitingQuestion(false);
// A running/waiting runtime for the currently selected session is already bound to this
// store and EventSource. Reopening it would replay state and can lose in-flight delivery.
if (result.alreadyActive && reconnectSameSession) return;
resetSession();
if (!result.alreadyActive) {
recordLifecycle("Resuming session");
setAgentActive(true);
}
setActiveSessionId(id);
// The backend has now completed clear/rebind. Recreate a same-id source with its cursor;
// switching ids naturally creates a new source and resets the cursor in the stream hook.
if (!result.alreadyActive && reconnectSameSession) {
setStreamGeneration((value) => value + 1);
}
// Paint the persisted re-entry phase while the replacement stream starts replaying.
// The manifest's `phase` is the 1-based current phase (1..8).
try {
const m = (await getSession(id)) as { phase?: number };
@@ -126,10 +133,6 @@ export function AppShell() {
/* non-fatal: the first gate will set the phase */
}
} catch {
// Revert the optimistic switch and restore the panel.
resetSession();
setActiveSessionId(null);
if (s) setPanelSession(s);
toast.error("Failed to resume session.");
}
}