Files
kandev/apps/web/lib/plugins/conversation-source-scope.test.tsx
2026-09-17 10:39:30 +01:00

594 lines
19 KiB
TypeScript

/* eslint-disable max-lines-per-function -- the fixture owns one complete source-scope lifecycle. */
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SourceConversationScope } from "./conversation-source-scope";
import type { ConversationChangedPayload } from "@/lib/types/session-events";
import { PluginConversationScopeProvider, pluginConversationApi } from "./conversation-host";
const transport = vi.hoisted(() => ({
request: vi.fn(),
on: vi.fn(),
onConnectionStatus: vi.fn(() => () => undefined),
changeListener: null as ((message: { payload: ConversationChangedPayload }) => void) | null,
removalListener: null as ((message: { payload: { session_id: string } }) => void) | null,
}));
const SESSION_ID = "session-1";
const CREATED_AT = "2026-09-16T12:00:00Z";
const PLUGIN_ID = "plugin-history";
const CONVERSATION_SUBSCRIBE_ACTION = "session.conversation.subscribe";
vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://host" }) }));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => transport,
}));
function response(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function Harness() {
const state = pluginConversationApi.useSessionMessages({ sessionId: SESSION_ID, sort: "asc" });
return (
<>
<span data-testid="messages">{state.messages.map((item) => item.content).join("|")}</span>
<span data-testid="removed">{String(state.removed)}</span>
</>
);
}
describe("source conversation scope", () => {
beforeEach(() => {
let pageRevision = "0";
transport.changeListener = null;
transport.removalListener = null;
transport.on.mockReset();
transport.on.mockImplementation((action: string, listener: typeof transport.changeListener) => {
if (action === "session.conversation.changed") transport.changeListener = listener;
if (action === "session.removed") {
transport.removalListener = listener as typeof transport.removalListener;
}
return () => {
if (action === "session.conversation.changed") transport.changeListener = null;
if (action === "session.removed") transport.removalListener = null;
};
});
transport.request.mockReset();
transport.request.mockImplementation((action: string, payload: { scope_id?: string }) => {
if (action === CONVERSATION_SUBSCRIBE_ACTION) {
return Promise.resolve({
success: true,
protocol_version: 2,
scope_id: payload.scope_id,
session_id: SESSION_ID,
epoch: "epoch-1",
revision: pageRevision,
});
}
return Promise.resolve({ success: true });
});
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/conversation/binding")) {
return Promise.resolve(
response({
bindingToken: "binding-1",
generation: 7,
expiresAt: "2099-01-01T00:00:00Z",
}),
);
}
return Promise.resolve(
response({
messages: [],
hasMore: false,
cursor: null,
epoch: "epoch-1",
revision: pageRevision,
}),
);
}),
);
Object.defineProperty(transport, "setPageRevision", {
configurable: true,
value: (revision: string) => {
pageRevision = revision;
},
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("uses source reads and applies a matching ID update without rereading", async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<Harness />
</PluginConversationScopeProvider>,
);
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe(""));
expect(transport.request).toHaveBeenCalledWith(
CONVERSATION_SUBSCRIBE_ACTION,
expect.objectContaining({ consumer_kind: "plugin", session_id: SESSION_ID }),
);
expect(
transport.request.mock.calls.find(
([action]) => action === CONVERSATION_SUBSCRIBE_ACTION,
)?.[1],
).not.toHaveProperty("task_id");
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(`/conversation/v2/task-sessions/${SESSION_ID}/messages`),
expect.anything(),
);
const subscribePayload = transport.request.mock.calls.find(
([action]) => action === CONVERSATION_SUBSCRIBE_ACTION,
)?.[1] as { scope_id: string };
act(() => {
transport.changeListener?.({
payload: {
protocol_version: 2,
scope_id: subscribePayload.scope_id,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: "0",
revision: "1",
operations: [
{
kind: "upsert",
entity: "message",
id: "message-1",
message: {
id: "message-1",
taskId: "task-1",
sessionId: SESSION_ID,
authorType: "user",
type: "message",
content: "from source",
createdAt: CREATED_AT,
updatedAt: CREATED_AT,
},
},
],
},
});
});
await waitFor(() => expect(screen.getByTestId("messages").textContent).toBe("from source"));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("delivers live updates from every task to a complete-session query", async () => {
function CompleteSessionHarness() {
const state = pluginConversationApi.useSessionMessages({
sessionId: SESSION_ID,
taskId: null,
sort: "asc",
});
return (
<span data-testid="complete-session-messages">
{state.messages.map((item) => item.content).join("|")}
</span>
);
}
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<CompleteSessionHarness />
</PluginConversationScopeProvider>,
);
await waitFor(() => expect(transport.changeListener).not.toBeNull());
const scopeID = transport.request.mock.calls.find(
([action]) => action === CONVERSATION_SUBSCRIBE_ACTION,
)?.[1].scope_id as string;
act(() => {
transport.changeListener?.({
payload: {
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: "0",
revision: "1",
operations: [
{
kind: "upsert",
entity: "message",
id: "message-other-task",
message: {
id: "message-other-task",
taskId: "task-2",
sessionId: SESSION_ID,
authorType: "agent",
type: "message",
content: "other task",
createdAt: CREATED_AT,
updatedAt: CREATED_AT,
},
},
],
},
});
});
await waitFor(() =>
expect(screen.getByTestId("complete-session-messages").textContent).toBe("other task"),
);
});
it("repairs a revision gap through a fresh source page", async () => {
const setPageRevision = (
transport as typeof transport & { setPageRevision: (value: string) => void }
).setPageRevision;
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<Harness />
</PluginConversationScopeProvider>,
);
await waitFor(() => expect(transport.changeListener).not.toBeNull());
const scopeID = transport.request.mock.calls.find(
([action]) => action === CONVERSATION_SUBSCRIBE_ACTION,
)?.[1].scope_id as string;
setPageRevision("3");
act(() => {
transport.changeListener?.({
payload: {
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: "2",
revision: "3",
operations: [],
},
});
});
await waitFor(() =>
expect((globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(3),
);
expect(
transport.request.mock.calls.filter(([action]) => action === CONVERSATION_SUBSCRIBE_ACTION),
).toHaveLength(2);
});
it("projects terminal session removal from the core notification", async () => {
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<Harness />
</PluginConversationScopeProvider>,
);
await waitFor(() => expect(screen.getByTestId("removed").textContent).toBe("false"));
act(() => {
transport.removalListener?.({ payload: { session_id: SESSION_ID } });
});
await waitFor(() => expect(screen.getByTestId("removed").textContent).toBe("true"));
});
it("renews an expired binding for reads and reconnects", async () => {
const now = Date.now();
const clock = vi.spyOn(Date, "now").mockReturnValue(now);
const fetchMock = vi.mocked(fetch);
fetchMock.mockImplementation(async () =>
response({
bindingToken: Date.now() === now ? "old" : "renewed",
generation: 7,
expiresAt: new Date(Date.now() + 600_000).toISOString(),
}),
);
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
try {
expect((await scope.ready()).bindingToken).toBe("old");
clock.mockReturnValue(now + 601_000);
const [first, second] = await Promise.all([scope.ready(), scope.renewContinuation("cursor")]);
expect(first.bindingToken).toBe("renewed");
expect(second.binding.bindingToken).toBe("renewed");
scope.reconnect();
await waitFor(() =>
expect(transport.request).toHaveBeenLastCalledWith(
CONVERSATION_SUBSCRIBE_ACTION,
expect.objectContaining({ binding_token: "renewed" }),
),
);
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
scope.close();
clock.mockRestore();
}
});
it("loads every turn page before publishing hydrated history", async () => {
function TurnsHarness() {
const state = pluginConversationApi.useSessionTurns(SESSION_ID);
return <span data-testid="turns">{state.turns.map((turn) => turn.id).join("|")}</span>;
}
const original = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = new URL(String(input));
if (!url.pathname.endsWith("/turns")) return original(input, init);
const second = url.searchParams.has("cursor");
const turns = Array.from({ length: second ? 1 : 100 }, (_, index) => ({
id: `turn-${second ? 100 : index}`,
sessionId: SESSION_ID,
taskId: "task-1",
startedAt: CREATED_AT,
completedAt: "2026-09-16T12:01:00Z",
}));
return response({
turns,
hasMore: !second,
cursor: second ? null : "next",
epoch: "epoch-1",
revision: "0",
});
});
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<TurnsHarness />
</PluginConversationScopeProvider>,
);
await waitFor(() =>
expect(screen.getByTestId("turns").textContent?.split("|")).toHaveLength(101),
);
expect(screen.getByTestId("turns").textContent).toContain("turn-100");
});
it("checks observed revisions without certifying missed updates", async () => {
vi.useFakeTimers();
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
try {
await scope.ready();
const scopeID = transport.request.mock.calls[0][1].scope_id;
const recover = vi.fn();
scope.subscribeRebind(recover);
const check = (revision: string) =>
scope.acceptChange({
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: revision,
revision,
check: true,
operations: [],
});
check("0");
await vi.advanceTimersByTimeAsync(1000);
expect(recover).not.toHaveBeenCalled();
check("1");
await vi.advanceTimersByTimeAsync(999);
expect(recover).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(recover).toHaveBeenCalledOnce();
} finally {
scope.close();
vi.useRealTimers();
}
});
it("retries a failed revision recovery instead of wedging the scope", async () => {
vi.useFakeTimers();
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
try {
await scope.ready();
const scopeID = transport.request.mock.calls[0][1].scope_id;
const recover = vi.fn();
scope.subscribeRebind(recover);
transport.request.mockRejectedValueOnce(new Error("temporary outage"));
scope.acceptChange({
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: "1",
revision: "2",
operations: [],
});
await Promise.resolve();
await Promise.resolve();
expect(recover).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(0);
expect(recover).toHaveBeenCalledOnce();
} finally {
scope.close();
vi.useRealTimers();
}
});
it("collapses an oversized pending buffer into one recovery", async () => {
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
const applied: string[] = [];
try {
await scope.ready();
const scopeID = transport.request.mock.calls[0][1].scope_id;
scope.subscribe(
(event) => {
if (event.event_type === "message.added") {
const payload = event.payload as { content?: unknown };
applied.push(String(payload.content));
}
return true;
},
"messages",
"all",
);
const recover = vi.fn(() => {
scope.setSourceSnapshot("epoch-1", "257");
scope.commitSnapshot("messages", "all");
});
scope.subscribeRebind(recover);
for (let revision = 1; revision <= 257; revision += 1) {
scope.acceptChange({
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: String(revision - 1),
revision: String(revision),
operations: [
{
kind: "upsert",
entity: "message",
id: `message-${revision}`,
message: { content: `message-${revision}`, taskId: "task-1" },
},
],
});
}
await waitFor(() => expect(recover).toHaveBeenCalledOnce());
expect(applied).toEqual([]);
scope.acceptChange({
protocol_version: 2,
scope_id: scopeID,
session_id: SESSION_ID,
epoch: "epoch-1",
base_revision: "257",
revision: "258",
operations: [
{
kind: "upsert",
entity: "message",
id: "message-current",
message: { content: "current", taskId: "task-1" },
},
],
});
expect(applied).toEqual(["current"]);
} finally {
scope.close();
}
});
it("terminates the plugin scope on server authorization removal", async () => {
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
await scope.ready();
scope.acceptChange({
scope_id: transport.request.mock.calls[0][1].scope_id,
session_id: SESSION_ID,
terminal: true,
});
expect(scope.isTerminal()).toBe(true);
scope.close();
});
it("retries binding acquisition after a failed request", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
try {
await expect(scope.ready()).rejects.toThrow("offline");
await expect(scope.ready()).resolves.toMatchObject({ bindingToken: "binding-1" });
} finally {
scope.close();
}
});
it.each(["delivered", "closed"])(
"does not recover a revision check after %s",
async (outcome) => {
vi.useFakeTimers();
const scope = new SourceConversationScope(
PLUGIN_ID,
"task-1",
SESSION_ID,
new AbortController(),
);
try {
await scope.ready();
const recover = vi.fn();
scope.subscribeRebind(recover);
const frame = {
protocol_version: 2,
scope_id: transport.request.mock.calls[0][1].scope_id,
session_id: SESSION_ID,
epoch: "epoch-1",
revision: "1",
operations: [],
};
scope.acceptChange({ ...frame, base_revision: "1", check: true });
if (outcome === "closed") scope.close();
else scope.acceptChange({ ...frame, base_revision: "0" });
await vi.advanceTimersByTimeAsync(1000);
expect(recover).not.toHaveBeenCalled();
} finally {
scope.close();
vi.useRealTimers();
}
},
);
it("does not publish partial turns when a continuation fails", async () => {
function FailedTurnsHarness() {
const state = pluginConversationApi.useSessionTurns(SESSION_ID);
return (
<span data-testid="turn-result">{`${state.turns.length}:${state.hydrated}:${state.error?.code ?? "none"}`}</span>
);
}
const original = vi.mocked(fetch).getMockImplementation()!;
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = new URL(String(input));
if (!url.pathname.endsWith("/turns")) return original(input, init);
if (url.searchParams.has("cursor"))
return new Response(
JSON.stringify({
error: { code: "upstream_failure", retryable: true },
}),
{ status: 500 },
);
return response({
turns: [
{
id: "turn-1",
sessionId: SESSION_ID,
taskId: "task-1",
startedAt: CREATED_AT,
},
],
hasMore: true,
cursor: "next",
epoch: "epoch-1",
revision: "0",
});
});
render(
<PluginConversationScopeProvider pluginId={PLUGIN_ID} taskId="task-1" sessionId={SESSION_ID}>
<FailedTurnsHarness />
</PluginConversationScopeProvider>,
);
await waitFor(() =>
expect(screen.getByTestId("turn-result").textContent).toBe("0:false:upstream_failure"),
);
});
});