fix: harden resume and SSE replay
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import { createSession, listSessions, prewarmRuntime } from "./sessions";
|
||||
import { createSession, listSessions, prewarmRuntime, resumeSession } from "./sessions";
|
||||
import {
|
||||
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
||||
deleteSession, getSessionDocuments,
|
||||
@@ -36,6 +36,14 @@ test("listSessions GETs the array", async () => {
|
||||
expect(rows[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
test("resumeSession returns the typed runtime disposition", async () => {
|
||||
server.use(http.post("http://localhost:8787/sessions/s1/resume", () =>
|
||||
HttpResponse.json({ id: "s1", alreadyActive: false })));
|
||||
|
||||
const result: { id: string; alreadyActive: boolean } = await resumeSession("s1");
|
||||
expect(result).toEqual({ id: "s1", alreadyActive: false });
|
||||
});
|
||||
|
||||
test("renameSession POSTs {name}", async () => {
|
||||
let body: unknown = null;
|
||||
server.use(http.post("http://localhost:8787/sessions/s1/rename", async ({ request }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch } from "./client";
|
||||
import type { SessionSummary, SessionDocument, UiResponse } from "./types";
|
||||
import type { ResumeSessionResult, SessionSummary, SessionDocument, UiResponse } from "./types";
|
||||
|
||||
export const createSession = (i: { question: string; name?: string }) =>
|
||||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
||||
@@ -29,7 +29,7 @@ export const closeSession = (id: string) =>
|
||||
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
|
||||
|
||||
export const resumeSession = (id: string) =>
|
||||
apiFetch<void>(`/sessions/${id}/resume`, { method: "POST" });
|
||||
apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, { method: "POST" });
|
||||
|
||||
export const renameSession = (id: string, name: string) =>
|
||||
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
|
||||
|
||||
@@ -89,7 +89,7 @@ export type StreamEvent =
|
||||
};
|
||||
}
|
||||
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
|
||||
| { type: "system_event"; event: string; [k: string]: unknown };
|
||||
| { type: "system_event"; event: string };
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
@@ -104,6 +104,11 @@ export interface SessionSummary {
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface ResumeSessionResult {
|
||||
id: string;
|
||||
alreadyActive: boolean;
|
||||
}
|
||||
|
||||
export interface SessionDocument {
|
||||
phase: string;
|
||||
key: string;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,29 @@ test("ui_request sets pendingWidget", () => {
|
||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("u1");
|
||||
});
|
||||
|
||||
test("the same gate descriptor id is handled once even after pending is cleared", () => {
|
||||
const store = useSessionStore.getState();
|
||||
const gate = {
|
||||
type: "ui_request" as const,
|
||||
ui_request: { id: "gate-1", widget: "select", title: "Review tables" },
|
||||
};
|
||||
|
||||
store.applyEvent(gate);
|
||||
store.applyEvent({
|
||||
type: "ui_request",
|
||||
ui_request: { ...gate.ui_request, title: "Duplicate transport copy" },
|
||||
});
|
||||
store.clearPending();
|
||||
store.applyEvent(gate);
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
expect(state.pendingWidget).toBeNull();
|
||||
expect(state.activityLog.filter((entry) => entry.kind === "gate")).toEqual([
|
||||
{ kind: "gate", phase: null, text: "Review tables" },
|
||||
]);
|
||||
expect(state.seenGateIds).toEqual(new Set(["gate-1"]));
|
||||
});
|
||||
|
||||
test("text_delta accumulates into transcript", () => {
|
||||
const s = useSessionStore.getState();
|
||||
s.applyEvent({ type: "text_delta", text: "Ana" });
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SessionState {
|
||||
lastSystemEvent: StreamEvent | null;
|
||||
currentPhase: string | null;
|
||||
phaseError: string | null;
|
||||
seenGateIds: Set<string>;
|
||||
// True while a Pi turn is in flight. Turned off by the backend-forwarded `agent_end`
|
||||
// system event — the only end-of-turn signal on the final workflow step, where no
|
||||
// follow-up gate arrives to release the spinner.
|
||||
@@ -62,6 +63,7 @@ const empty = {
|
||||
lastSystemEvent: null,
|
||||
currentPhase: null as string | null,
|
||||
phaseError: null as string | null,
|
||||
seenGateIds: new Set<string>(),
|
||||
agentActive: false,
|
||||
};
|
||||
|
||||
@@ -70,9 +72,11 @@ export const useSessionStore = create<SessionState>((set) => ({
|
||||
applyEvent: (e) =>
|
||||
set((st) => {
|
||||
if (e.type === "ui_request") {
|
||||
if (st.seenGateIds.has(e.ui_request.id)) return {};
|
||||
const currentPhase = phaseOf(e.ui_request.phase, st.currentPhase);
|
||||
return {
|
||||
pendingWidget: e.ui_request,
|
||||
seenGateIds: new Set([...st.seenGateIds, e.ui_request.id]),
|
||||
// The turn is blocked on the gate, not ended: keep the agent marked active.
|
||||
agentActive: true,
|
||||
currentPhase,
|
||||
|
||||
@@ -70,16 +70,46 @@ test("closes the stream on unmount", () => {
|
||||
expect(es.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("reconnects the same session when the generation changes", () => {
|
||||
test("a same-session generation reconnect includes the last consumed SSE id", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ generation }) => useSessionStream("s1", generation),
|
||||
{ initialProps: { generation: 0 } },
|
||||
);
|
||||
const first = FakeEventSource.instances[0];
|
||||
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "hello" }, "7"));
|
||||
|
||||
rerender({ generation: 1 });
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
expect(FakeEventSource.instances).toHaveLength(2);
|
||||
expect(FakeEventSource.instances[1].url).toBe(first.url);
|
||||
expect(FakeEventSource.instances[1].url).toBe(
|
||||
"http://localhost:8787/sessions/s1/events?lastEventId=7",
|
||||
);
|
||||
|
||||
act(() =>
|
||||
FakeEventSource.instances[1].emitNamed(
|
||||
"text_delta",
|
||||
{ type: "text_delta", text: " world" },
|
||||
"8",
|
||||
),
|
||||
);
|
||||
expect(useSessionStore.getState().transcript).toEqual([
|
||||
{ role: "assistant", text: "hello world" },
|
||||
]);
|
||||
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "assistant"))
|
||||
.toEqual([{ kind: "assistant", phase: null, text: "hello world" }]);
|
||||
});
|
||||
|
||||
test("changing the session resets the manual reconnect cursor", () => {
|
||||
const { rerender } = renderHook(
|
||||
({ sessionId }) => useSessionStream(sessionId),
|
||||
{ initialProps: { sessionId: "s1" as string | null } },
|
||||
);
|
||||
const first = FakeEventSource.instances[0];
|
||||
act(() => first.emitNamed("info", { type: "info", text: "ready" }, "12"));
|
||||
|
||||
rerender({ sessionId: "s2" });
|
||||
|
||||
expect(first.closed).toBe(true);
|
||||
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BASE } from "../api/client";
|
||||
import { joinBackendPath } from "../api/runtime-config";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
@@ -7,15 +7,24 @@ import type { StreamEvent } from "../api/types";
|
||||
export function useSessionStream(sessionId: string | null, generation = 0) {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const applyEvent = useSessionStore((s) => s.applyEvent);
|
||||
const cursor = useRef({ sessionId: null as string | null, lastEventId: "" });
|
||||
|
||||
if (cursor.current.sessionId !== sessionId) {
|
||||
cursor.current = { sessionId, lastEventId: "" };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events`));
|
||||
const query = cursor.current.lastEventId
|
||||
? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}`
|
||||
: "";
|
||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
|
||||
const handle = (ev: MessageEvent) => {
|
||||
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
||||
try {
|
||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||
} catch {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
export class FakeEventSource {
|
||||
static instances: FakeEventSource[] = [];
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onmessage: ((e: { data: string; lastEventId: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
private listeners = new Map<string, Set<(e: { data: string }) => void>>();
|
||||
private listeners = new Map<string, Set<(e: { data: string; lastEventId: string }) => void>>();
|
||||
constructor(public url: string) {
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
/** Dispatch to the default onmessage handler (unnamed `event: message`). */
|
||||
emit(obj: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(obj) });
|
||||
emit(obj: unknown, lastEventId = "") {
|
||||
this.onmessage?.({ data: JSON.stringify(obj), lastEventId });
|
||||
}
|
||||
/** Dispatch to handlers registered for a NAMED event (e.g. "ui_request"). */
|
||||
emitNamed(type: string, obj: unknown) {
|
||||
emitNamed(type: string, obj: unknown, lastEventId = "") {
|
||||
const data = JSON.stringify(obj);
|
||||
for (const h of this.listeners.get(type) ?? []) h({ data });
|
||||
for (const h of this.listeners.get(type) ?? []) h({ data, lastEventId });
|
||||
}
|
||||
addEventListener(type: string, handler: (e: { data: string }) => void) {
|
||||
addEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type)!.add(handler);
|
||||
}
|
||||
removeEventListener(type: string, handler: (e: { data: string }) => void) {
|
||||
removeEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||
this.listeners.get(type)?.delete(handler);
|
||||
}
|
||||
close() {
|
||||
|
||||
Reference in New Issue
Block a user