Files
kandev/apps/web/lib/plugins/conversation-host.test.tsx
2026-09-15 19:05:40 +01:00

1273 lines
42 KiB
TypeScript

/* eslint-disable max-lines,sonarjs/no-duplicate-string,max-lines-per-function -- This integration fixture covers the full conversation lifecycle with many fixture literals. */
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RawSessionEvent } from "@/lib/ws/client";
import type { PluginSessionMessagesState, PluginSessionTurnsState } from "./types";
import { PluginConversationScopeProvider, pluginConversationApi } from "./conversation-host";
const transport = vi.hoisted(() => ({
request: vi.fn(),
listener: null as ((event: RawSessionEvent) => void) | null,
statusListener: null as ((status: "connected" | "disconnected") => void) | null,
}));
vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://host" }) }));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({
request: transport.request,
onRawSessionEvent(listener: (event: RawSessionEvent) => void) {
transport.listener = listener;
return () => {
if (transport.listener === listener) transport.listener = null;
};
},
onConnectionStatus(listener: (status: "connected" | "disconnected") => void) {
transport.statusListener = listener;
return () => {
if (transport.statusListener === listener) transport.statusListener = null;
};
},
}),
}));
let currentState: PluginSessionMessagesState | null = null;
let currentTurnsState: PluginSessionTurnsState | null = null;
const SESSION_SUBSCRIBE_ACTION = "session.subscribe";
const SESSION_ACK_ACTION = "session.ack";
const BINDING_PATH_SUFFIX = "/conversation/binding";
const FAR_FUTURE_EXPIRY = "2099-01-01T00:00:00Z";
const MESSAGE_CREATED_AT = "2026-09-07T12:00:00Z";
const SESSION_REMOVED_EVENT = "session.removed";
function Harness({ sessionId, taskId }: { sessionId: string | null; taskId?: string | null }) {
currentState = pluginConversationApi.useSessionMessages({
sessionId,
taskId,
authorTypes: ["user"],
sort: "desc",
pageSize: 1,
});
return (
<div>
<span data-testid="messages">
{currentState.messages.map((message) => message.content).join("|")}
</span>
<span data-testid="removed">{String(currentState.removed)}</span>
<span data-testid="error">{currentState.error?.code ?? ""}</span>
</div>
);
}
function TurnsHarness({ sessionId, taskId }: { sessionId: string | null; taskId?: string | null }) {
currentTurnsState = pluginConversationApi.useSessionTurns(sessionId, taskId);
return (
<div>
<span data-testid="turns">{currentTurnsState.turns.map((turn) => turn.id).join("|")}</span>
<span data-testid="turns-removed">{String(currentTurnsState.removed)}</span>
<span data-testid="turns-error">{currentTurnsState.error?.code ?? ""}</span>
</div>
);
}
function renderTurnsHarness(sessionId: string | null, taskId?: string | null) {
return render(
<PluginConversationScopeProvider
pluginId="plugin-history"
taskId="task-1"
sessionId={sessionId}
>
<TurnsHarness sessionId={sessionId} taskId={taskId} />
</PluginConversationScopeProvider>,
);
}
function renderHarness(sessionId: string | null, taskId?: string | null) {
return render(
<PluginConversationScopeProvider
pluginId="plugin-history"
taskId="task-1"
sessionId={sessionId}
>
<Harness sessionId={sessionId} taskId={taskId} />
</PluginConversationScopeProvider>,
);
}
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
const TEST_MESSAGE_ID = "message-1";
const MESSAGE_ADDED_EVENT = "message.added";
function requiredEventPayload(eventType: string): Record<string, unknown> {
switch (eventType) {
case MESSAGE_ADDED_EVENT:
return {
message_id: TEST_MESSAGE_ID,
author_type: "user",
content: "",
created_at: MESSAGE_CREATED_AT,
};
case "message.updated":
return {
message_id: TEST_MESSAGE_ID,
author_type: "user",
content: "",
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
};
case "message.deleted":
return { message_id: TEST_MESSAGE_ID };
case "session.turn.started":
return { id: "turn-1", started_at: MESSAGE_CREATED_AT };
case "session.turn.completed":
return {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
};
default:
return {};
}
}
function event(
sequence: number,
eventType: string,
payload: Record<string, unknown>,
taskId: string | null = "task-1",
): RawSessionEvent {
const requiredPayload = requiredEventPayload(eventType);
return {
type: "session.event",
protocol_version: 1,
event_type: eventType,
session_id: "session-1",
task_id: taskId,
sequence,
event_id: `event-${sequence}`,
payload: {
session_id: "session-1",
...(taskId !== null ? { task_id: taskId } : {}),
...requiredPayload,
...payload,
type: eventType,
},
};
}
beforeEach(() => {
currentState = null;
currentTurnsState = null;
transport.listener = null;
transport.statusListener = null;
transport.request.mockReset();
transport.request.mockImplementation((action: string) => {
if (action === SESSION_SUBSCRIBE_ACTION) {
return Promise.resolve({
success: true,
snapshot_token: "snapshot-1",
resume_token: "resume-0",
consumer_id: "consumer-1",
event_watermark: 0,
expires_at: FAR_FUTURE_EXPIRY,
result: "fresh",
});
}
if (action === SESSION_ACK_ACTION) {
return Promise.resolve({ success: true, resume_token: "resume-next" });
}
return Promise.resolve({ success: true });
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("plugin conversation Host facade", () => {
it("keeps nullable sessions empty without transport activity", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
renderHarness(null);
expect(screen.getByTestId("messages").textContent).toBe("");
expect(currentState?.hasMore).toBe(false);
expect(currentState?.hydrated).toBe(false);
expect(await currentState?.loadMore()).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(transport.request).not.toHaveBeenCalled();
});
it("retries initial binding setup after a transient failure", async () => {
let bindingFetches = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
bindingFetches += 1;
if (bindingFetches === 1) {
return Promise.resolve(
response(
{ error: { code: "upstream_failure", message: "temporary", retryable: true } },
503,
),
);
}
return Promise.resolve(
response({ bindingToken: "binding-2", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return Promise.resolve(response({ messages: [], hasMore: false, cursor: null }));
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(currentState?.error?.retryable).toBe(true));
act(() => currentState?.retry());
await waitFor(() => expect(currentState?.hydrated).toBe(true));
expect(bindingFetches).toBe(2);
});
it("retries initial ordered subscription after a transient failure", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith(BINDING_PATH_SUFFIX)
? Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
)
: Promise.resolve(response({ messages: [], hasMore: false, cursor: null })),
);
vi.stubGlobal("fetch", fetchMock);
transport.request.mockRejectedValueOnce(new Error("temporary subscribe failure"));
renderHarness("session-1");
await waitFor(() => expect(currentState?.error?.retryable).toBe(true));
act(() => currentState?.retry());
await waitFor(() => expect(currentState?.hydrated).toBe(true));
expect(
transport.request.mock.calls.filter(([action]) => action === SESSION_SUBSCRIBE_ACTION),
).toHaveLength(2);
});
it("commits the authorized snapshot before projecting and acknowledging ordered live events", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return Promise.resolve(
response({
messages: [
{
id: "message-1",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "original",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: false,
cursor: null,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("original"));
expect(currentState?.hydrated).toBe(true);
act(() => {
transport.listener?.(
event(1, "message.updated", {
message_id: "message-1",
author_type: "user",
content: "updated",
message_type: "message",
created_at: MESSAGE_CREATED_AT,
updated_at: "2026-09-07T12:01:00Z",
}),
);
});
expect(screen.getByTestId("messages").textContent).toBe("updated");
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_ACK_ACTION,
expect.objectContaining({ sequence: 1 }),
),
);
await act(async () => {
await Promise.resolve();
});
const initialConsumerId = "consumer-1";
act(() => {
transport.statusListener?.("disconnected");
});
act(() => {
transport.statusListener?.("connected");
});
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_SUBSCRIBE_ACTION,
expect.objectContaining({
consumer_id: initialConsumerId,
last_seen_sequence: 1,
resume_token: "resume-next",
}),
),
);
act(() => {
transport.listener?.(event(2, "message.deleted", { message_id: "message-1" }));
transport.listener?.(event(3, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("messages").textContent).toBe("updated");
expect(screen.getByTestId("removed").textContent).toBe("true");
expect(currentState?.hasMore).toBe(false);
});
it("recovers an expired continuation without remount and continues the load", async () => {
let subscribeCount = 0;
transport.request.mockImplementation((action: string) => {
if (action === SESSION_SUBSCRIBE_ACTION) {
subscribeCount += 1;
return Promise.resolve({
success: true,
snapshot_token: `snapshot-${subscribeCount}`,
resume_token: `resume-${subscribeCount}`,
consumer_id: `consumer-${subscribeCount}`,
event_watermark: 0,
expires_at: subscribeCount === 1 ? "2020-01-01T00:00:00Z" : FAR_FUTURE_EXPIRY,
result: "fresh",
});
}
if (action === SESSION_ACK_ACTION) {
return Promise.resolve({ success: true, resume_token: "resume-next" });
}
return Promise.resolve({ success: true });
});
let messagePages = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
if (url.endsWith("/conversation/continuation/renew")) {
return Promise.resolve(
response({ error: { code: "invalid_query", message: "expired", retryable: false } }, 400),
);
}
messagePages += 1;
const cursor = new URL(url).searchParams.get("cursor");
if (!cursor) {
return Promise.resolve(
response({
messages: [
{
id: "message-newest",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: `newest-page-${messagePages}`,
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: true,
cursor: messagePages === 1 ? "cursor-expired" : "cursor-fresh",
}),
);
}
expect(cursor).toBe("cursor-fresh");
return Promise.resolve(
response({
messages: [
{
id: "message-older",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "older-page",
createdAt: "2026-09-07T11:59:00Z",
updatedAt: "2026-09-07T11:59:00Z",
},
],
hasMore: false,
cursor: null,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(currentState?.hasMore).toBe(true));
await expect(currentState?.loadMore()).resolves.toBe(1);
expect(subscribeCount).toBe(2);
expect(fetchMock).not.toHaveBeenCalledWith(
expect.stringContaining("/conversation/continuation/renew"),
expect.anything(),
);
await waitFor(() => expect(screen.getByTestId("messages").textContent).toContain("older-page"));
});
});
describe("strict ordered event validation poisons instead of projecting", () => {
function stubEmptySnapshot() {
const fetchMock = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith(BINDING_PATH_SUFFIX)
? Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
)
: Promise.resolve(response({ messages: [], hasMore: false, cursor: null })),
);
vi.stubGlobal("fetch", fetchMock);
}
it("does not treat a removal with a mismatched payload session_id as terminal", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, { session_id: "session-other" }, null));
});
expect(screen.getByTestId("removed").textContent).toBe("false");
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_SUBSCRIBE_ACTION,
expect.objectContaining({
replace_cursor: true,
consumer_kind: "plugin",
plugin_id: "plugin-history",
session_id: "session-1",
}),
),
);
expect(screen.getByTestId("removed").textContent).toBe("false");
});
it("projects attachment-only messages with empty content through add and update", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(1, "message.added", {
message_id: "message-attachment-only",
content: "",
created_at: MESSAGE_CREATED_AT,
}),
);
});
expect(currentState?.messages).toHaveLength(1);
expect(currentState?.messages[0]?.content).toBe("");
act(() => {
transport.listener?.(
event(2, "message.updated", {
message_id: "message-attachment-only",
content: "",
created_at: MESSAGE_CREATED_AT,
updated_at: "2026-09-07T12:01:00Z",
}),
);
});
expect(currentState?.messages).toHaveLength(1);
expect(currentState?.messages[0]).toMatchObject({
id: "message-attachment-only",
content: "",
updatedAt: "2026-09-07T12:01:00Z",
});
});
it("does not let a retry page overwrite a live event projected mid-fetch", async () => {
let resolveRetryPage: ((value: Response) => void) | undefined;
const retryPage = new Promise<Response>((resolve) => {
resolveRetryPage = resolve;
});
let messageFetches = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
messageFetches += 1;
if (messageFetches === 1) {
return Promise.resolve(
response({
messages: [
{
id: "message-original",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "original",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: true,
cursor: "cursor-1",
}),
);
}
if (messageFetches === 2) {
return Promise.reject(new TypeError("network down"));
}
return retryPage;
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("original"));
await expect(currentState?.loadMore()).rejects.toBeTruthy();
await waitFor(() => expect(currentState?.error?.retryable).toBe(true));
act(() => currentState?.retry());
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
// A live update lands while the retry page is still in flight.
act(() => {
transport.listener?.(
event(1, "message.added", {
message_id: "message-live",
content: "live-during-retry",
created_at: "2026-09-07T12:00:01Z",
updated_at: "2026-09-07T12:00:01Z",
}),
);
});
expect(screen.getByTestId("messages").textContent).toBe("");
await act(async () => {
resolveRetryPage?.(
response({
messages: [
{
id: "message-original",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "original",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: false,
cursor: null,
}),
);
await Promise.resolve();
});
await waitFor(() =>
expect(screen.getByTestId("messages").textContent).toContain("live-during-retry"),
);
expect(screen.getByTestId("messages").textContent).toContain("original");
});
it("projects live events from the subscribe watermark, not sequence one", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith(BINDING_PATH_SUFFIX)
? Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
)
: Promise.resolve(response({ messages: [], hasMore: false, cursor: null })),
);
vi.stubGlobal("fetch", fetchMock);
// An existing non-empty session subscribes at event_watermark 3; live
// frames therefore start at sequence 4 and must not park waiting for 1.
transport.request.mockImplementation((action: string) => {
if (action === SESSION_SUBSCRIBE_ACTION) {
return Promise.resolve({
success: true,
snapshot_token: "snapshot-1",
resume_token: "resume-0",
consumer_id: "consumer-1",
event_watermark: 3,
expires_at: FAR_FUTURE_EXPIRY,
result: "fresh",
});
}
if (action === SESSION_ACK_ACTION) {
return Promise.resolve({ success: true, resume_token: "resume-next" });
}
return Promise.resolve({ success: true });
});
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(4, "message.added", { message_id: "message-4", content: "live-four" }),
);
});
expect(screen.getByTestId("messages").textContent).toBe("live-four");
});
it("accepts session.removed whose task_id differs from the session task", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, { task_id: "task-moved" }, null));
});
expect(screen.getByTestId("removed").textContent).toBe("true");
});
it("does not resurrect rows from events delivered after terminal removal", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null));
transport.listener?.(event(2, "message.added", { message_id: "late", content: "late" }));
});
expect(screen.getByTestId("removed").textContent).toBe("true");
expect(screen.getByTestId("messages").textContent).toBe("");
});
it("retains projected messages when session removal follows message deletions", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(1, MESSAGE_ADDED_EVENT, {
message_id: "message-terminal",
content: "retained after removal",
}),
);
});
await waitFor(() =>
expect(screen.getByTestId("messages").textContent).toBe("retained after removal"),
);
act(() => {
transport.listener?.(event(2, "message.deleted", { message_id: "message-terminal" }));
transport.listener?.(event(3, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("messages").textContent).toBe("retained after removal");
expect(screen.getByTestId("removed").textContent).toBe("true");
});
it("recovers through a durable rebind after a message with a missing required id", async () => {
stubEmptySnapshot();
renderHarness("session-1");
await waitFor(() => expect(currentState?.hydrated).toBe(true));
act(() => {
transport.listener?.(event(1, "message.added", { message_id: "", content: "no-id" }));
});
expect(screen.getByTestId("messages").textContent).toBe("");
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_SUBSCRIBE_ACTION,
expect.objectContaining({ replace_cursor: true }),
),
);
await act(async () => {
await Promise.resolve();
});
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("removed").textContent).toBe("true");
});
});
it("does not fetch after terminal removal even through a captured loadMore callback", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) =>
String(input).endsWith(BINDING_PATH_SUFFIX)
? Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
)
: Promise.resolve(response({ messages: [], hasMore: true, cursor: "cursor-1" })),
);
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(currentState?.hasMore).toBe(true));
const loadMore = currentState?.loadMore;
act(() => transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null)));
await expect(loadMore?.()).resolves.toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("commits terminal removal while the initial snapshot is still pending", async () => {
let resolveSnapshot: ((value: Response) => void) | undefined;
const snapshot = new Promise<Response>((resolve) => {
resolveSnapshot = resolve;
});
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return snapshot;
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("removed").textContent).toBe("true");
await act(async () => {
resolveSnapshot?.(
response({
messages: [
{
id: "message-late",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "must not resurrect",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: true,
cursor: "late-cursor",
}),
);
await snapshot;
});
expect(screen.getByTestId("removed").textContent).toBe("true");
expect(screen.getByTestId("error").textContent).toBe("");
expect(screen.getByTestId("messages").textContent).toBe("");
});
it("replays add, update, and delete events after a blocked snapshot without resurrection", async () => {
let resolveSnapshot: ((value: Response) => void) | undefined;
const snapshot = new Promise<Response>((resolve) => {
resolveSnapshot = resolve;
});
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return snapshot;
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(
event(1, MESSAGE_ADDED_EVENT, {
message_id: "message-new",
author_type: "user",
content: "temporary",
message_type: "message",
created_at: "2026-09-07T12:02:00Z",
}),
);
transport.listener?.(
event(2, "message.updated", {
message_id: "message-base",
author_type: "user",
content: "updated",
message_type: "message",
created_at: MESSAGE_CREATED_AT,
updated_at: "2026-09-07T12:03:00Z",
}),
);
transport.listener?.(event(3, "message.deleted", { message_id: "message-new" }));
});
expect(screen.getByTestId("messages").textContent).toBe("");
await act(async () => {
resolveSnapshot?.(
response({
messages: [
{
id: "message-base",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: "original",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: false,
cursor: null,
}),
);
await snapshot;
});
expect(screen.getByTestId("messages").textContent).toBe("updated");
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_ACK_ACTION,
expect.objectContaining({ sequence: 3 }),
),
);
});
it("keeps turns terminal when removal wins the initial snapshot race", async () => {
let resolveSnapshot: ((value: Response) => void) | undefined;
const snapshot = new Promise<Response>((resolve) => {
resolveSnapshot = resolve;
});
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return snapshot;
});
vi.stubGlobal("fetch", fetchMock);
renderTurnsHarness("session-1");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(event(1, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("turns-removed").textContent).toBe("true");
await act(async () => {
resolveSnapshot?.(
response({
turns: [
{
id: "turn-late",
taskId: "task-1",
sessionId: "session-1",
startedAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
}),
);
await snapshot;
});
expect(screen.getByTestId("turns-removed").textContent).toBe("true");
expect(screen.getByTestId("turns-error").textContent).toBe("");
expect(screen.getByTestId("turns").textContent).toBe("");
});
describe("plugin conversation ordering and binding lifecycle", () => {
it("holds a forward sequence gap and drains events when the missing sequence arrives", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return Promise.resolve(response({ messages: [], hasMore: false, cursor: null }));
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(
event(2, MESSAGE_ADDED_EVENT, {
message_id: "message-two",
author_type: "user",
content: "second",
message_type: "message",
created_at: "2026-09-07T12:02:00Z",
}),
);
});
expect(screen.getByTestId("messages").textContent).toBe("");
expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe(
false,
);
act(() => {
transport.listener?.(
event(1, MESSAGE_ADDED_EVENT, {
message_id: "message-one",
author_type: "user",
content: "first",
message_type: "message",
created_at: "2026-09-07T12:01:00Z",
}),
);
});
expect(screen.getByTestId("messages").textContent).toBe("second|first");
await waitFor(() =>
expect(transport.request).toHaveBeenCalledWith(
SESSION_ACK_ACTION,
expect.objectContaining({ sequence: 2 }),
),
);
});
it("refreshes a loader binding and atomically replaces a superseded generation", async () => {
let bindingReads = 0;
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
bindingReads += 1;
const expiresAt =
bindingReads === 1 ? new Date(Date.now() + 60_000).toISOString() : FAR_FUTURE_EXPIRY;
return Promise.resolve(
response({
bindingToken: `binding-${bindingReads}`,
generation: bindingReads + 6,
expiresAt,
}),
);
}
expect(new Headers(init?.headers).get("X-Kandev-Plugin-Binding")).toBe("binding-2");
return Promise.resolve(response({ messages: [], hasMore: false, cursor: null }));
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(currentState?.loading).toBe(false));
expect(bindingReads).toBe(2);
const subscribeGenerations = transport.request.mock.calls
.filter(([action]) => action === SESSION_SUBSCRIBE_ACTION)
.map(([, request]) => request.generation);
expect(subscribeGenerations).toEqual([7, 8]);
expect(transport.request).toHaveBeenCalledWith(
"session.unsubscribe",
expect.objectContaining({ generation: 7 }),
);
});
});
it("atomically rebinds and replaces snapshots past a poison event", async () => {
let snapshotReads = 0;
let subscribes = 0;
transport.request.mockImplementation((action: string) => {
if (action === SESSION_SUBSCRIBE_ACTION) {
subscribes += 1;
return Promise.resolve({
success: true,
snapshot_token: `snapshot-${subscribes}`,
resume_token: `resume-${subscribes}`,
consumer_id: "consumer-1",
event_watermark: subscribes - 1,
expires_at: FAR_FUTURE_EXPIRY,
result: "fresh",
});
}
if (action === SESSION_ACK_ACTION) {
return Promise.resolve({ success: true, resume_token: "resume-next" });
}
return Promise.resolve({ success: true });
});
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
snapshotReads += 1;
return Promise.resolve(
response({
messages: [
{
id: "message-1",
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: snapshotReads === 1 ? "before poison" : "after poison",
createdAt: MESSAGE_CREATED_AT,
updatedAt: MESSAGE_CREATED_AT,
},
],
hasMore: false,
cursor: null,
}),
);
}),
);
renderHarness("session-1");
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("before poison"));
act(() => transport.listener?.(event(1, "unknown.event", {})));
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("after poison"));
expect(subscribes).toBe(2);
expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe(
false,
);
});
it("surfaces terminal removal after an earlier poison event without advancing ACK", async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
if (String(input).endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return Promise.resolve(response({ messages: [], hasMore: false, cursor: null }));
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(event(1, "unknown.event", {}));
transport.listener?.(event(2, SESSION_REMOVED_EVENT, {}, null));
});
expect(screen.getByTestId("removed").textContent).toBe("true");
expect(transport.request.mock.calls.some(([action]) => action === SESSION_ACK_ACTION)).toBe(
false,
);
});
describe("plugin conversation pagination", () => {
it("joins concurrent requests for one older-page continuation", async () => {
let page = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
page += 1;
return Promise.resolve(
response({
messages: [
{
id: `message-${page}`,
taskId: "task-1",
sessionId: "session-1",
authorType: "user",
type: "message",
content: `page-${page}`,
createdAt: `2026-09-07T12:0${page}:00Z`,
updatedAt: `2026-09-07T12:0${page}:00Z`,
},
],
hasMore: page === 1,
cursor: page === 1 ? "cursor-1" : null,
}),
);
});
vi.stubGlobal("fetch", fetchMock);
renderHarness("session-1");
await waitFor(() => expect(currentState?.hasMore).toBe(true));
let counts: number[] = [];
const loadMore = currentState!.loadMore;
await act(async () => {
counts = await Promise.all([loadMore(), loadMore()]);
});
expect(page).toBe(2);
expect(counts).toEqual([1, 1]);
expect(screen.getByTestId("messages").textContent).toContain("page-2");
});
});
describe("ordered turns convergence", () => {
function stubTurnsFetch() {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
return Promise.resolve(response({ turns: [], hasMore: false, cursor: null }));
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
it("drops a turn from live state when session.turn.removed arrives", async () => {
stubTurnsFetch();
renderTurnsHarness("session-1");
await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(1, "session.turn.started", {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: null,
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
transport.listener?.(
event(2, "session.turn.completed", {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: MESSAGE_CREATED_AT,
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
});
await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1));
act(() => {
transport.listener?.(event(3, "session.turn.removed", { id: "turn-1" }));
});
expect(currentTurnsState?.turns).toHaveLength(0);
expect(currentTurnsState?.hydrated).toBe(true);
expect(screen.getByTestId("turns-removed").textContent).toBe("false");
});
it("retains projected turns when session removal follows turn deletions", async () => {
stubTurnsFetch();
renderTurnsHarness("session-1");
await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(1, "session.turn.started", {
id: "turn-terminal",
started_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
transport.listener?.(
event(2, "session.turn.completed", {
id: "turn-terminal",
started_at: MESSAGE_CREATED_AT,
completed_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
});
await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1));
act(() => {
transport.listener?.(event(3, "session.turn.removed", { id: "turn-terminal" }));
transport.listener?.(event(4, SESSION_REMOVED_EVENT, {}, null));
});
expect(currentTurnsState?.turns.map((turn) => turn.id)).toEqual(["turn-terminal"]);
expect(screen.getByTestId("turns-removed").textContent).toBe("true");
});
it.each([
{ name: "inherited task", selectedTaskId: undefined, removalTaskId: "task-2", removed: false },
{ name: "explicit task", selectedTaskId: "task-1", removalTaskId: "task-1", removed: true },
{ name: "session-wide scope", selectedTaskId: null, removalTaskId: "task-2", removed: true },
])(
"applies turn removals only within $name",
async ({ selectedTaskId, removalTaskId, removed }) => {
stubTurnsFetch();
renderTurnsHarness("session-1", selectedTaskId);
await waitFor(() => expect(currentTurnsState?.hydrated).toBe(true));
act(() => {
transport.listener?.(
event(1, "session.turn.started", {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: null,
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
});
await waitFor(() => expect(currentTurnsState?.turns).toHaveLength(1));
act(() => {
transport.listener?.(event(2, "session.turn.removed", { id: "turn-1" }, removalTaskId));
});
expect(currentTurnsState?.turns).toHaveLength(removed ? 0 : 1);
},
);
it("does not let a retry turns page overwrite a live completion mid-fetch", async () => {
let resolveRetry: ((value: Response) => void) | undefined;
const retryPage = new Promise<Response>((resolve) => {
resolveRetry = resolve;
});
let turnFetches = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith(BINDING_PATH_SUFFIX)) {
return Promise.resolve(
response({ bindingToken: "binding-1", generation: 7, expiresAt: FAR_FUTURE_EXPIRY }),
);
}
turnFetches += 1;
if (turnFetches === 1) {
return Promise.reject(new TypeError("network down"));
}
return retryPage;
});
vi.stubGlobal("fetch", fetchMock);
renderTurnsHarness("session-1");
await waitFor(() => expect(currentTurnsState?.error?.retryable).toBe(true));
// Retry starts a fresh page that is still in flight when live events land.
act(() => currentTurnsState?.retry());
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
act(() => {
transport.listener?.(
event(1, "session.turn.started", {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: null,
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
transport.listener?.(
event(2, "session.turn.completed", {
id: "turn-1",
started_at: MESSAGE_CREATED_AT,
completed_at: MESSAGE_CREATED_AT,
created_at: MESSAGE_CREATED_AT,
updated_at: MESSAGE_CREATED_AT,
}),
);
});
await act(async () => {
resolveRetry?.(response({ turns: [], hasMore: false, cursor: null }));
await Promise.resolve();
});
// The buffered live events drain on top of the (empty) retry page instead
// of being lost, and the completion survives the page assignment.
await waitFor(() =>
expect(currentTurnsState?.turns.find((turn) => turn.id === "turn-1")?.completedAt).toBe(
MESSAGE_CREATED_AT,
),
);
});
});