diff --git a/apps/web/components/task/plugin-task-panel.test.tsx b/apps/web/components/task/plugin-task-panel.test.tsx
index 40aa763725..d465aa9a6e 100644
--- a/apps/web/components/task/plugin-task-panel.test.tsx
+++ b/apps/web/components/task/plugin-task-panel.test.tsx
@@ -8,6 +8,11 @@ vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record
) => unknown) =>
selector({
tasks: { activeTaskId: mockActiveTaskId, activeSessionId: mockActiveSessionId },
+ taskSessions: {
+ items: {
+ session_1: { id: "session_1", is_passthrough: false, name: "Session" },
+ },
+ },
}),
}));
@@ -53,6 +58,148 @@ describe("PluginTaskPanel", () => {
);
});
+ it("passes session kind and a presentation-scoped navigation facade", () => {
+ const onOpenMessage = vi.fn(() => ({ status: "accepted" as const }));
+ function Notes(props: {
+ sessionKind: string | null;
+ conversation: { openMessage: (messageId: string) => { status: string } };
+ }) {
+ return (
+
+ );
+ }
+ pluginRegistry
+ .forPlugin("plugin-a")
+ .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true });
+
+ render(
+ ,
+ );
+
+ screen.getByRole("button", { name: "managed" }).click();
+ expect(onOpenMessage).toHaveBeenCalledWith("message-1");
+ });
+
+ it("reports unavailable for invalid or rejected mobile navigation", () => {
+ let emptyResult = "";
+ let rejectedResult = "";
+ const onOpenMessage = vi.fn(() => ({ status: "unavailable" as const }));
+ function Notes(props: {
+ conversation: { openMessage: (messageId: string) => { status: string } };
+ }) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ pluginRegistry
+ .forPlugin("plugin-a")
+ .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes, mobileEnabled: true });
+
+ render(
+ ,
+ );
+
+ screen.getByRole("button", { name: "empty" }).click();
+ expect(emptyResult).toBe("unavailable");
+ expect(onOpenMessage).not.toHaveBeenCalled();
+
+ screen.getByRole("button", { name: "rejected" }).click();
+ expect(rejectedResult).toBe("unavailable");
+ expect(onOpenMessage).toHaveBeenCalledWith("message-1");
+ });
+});
+describe("PluginTaskPanel session lifecycle", () => {
+ it("keeps the panel bound to a deleted active session", () => {
+ function Notes(props: { sessionId: string | null }) {
+ return {props.sessionId}
;
+ }
+ pluginRegistry
+ .forPlugin("plugin-a")
+ .registerTaskPanel({ id: "notes", title: "Notes", Component: Notes });
+
+ const view = render(
+ ,
+ );
+ expect(screen.getByTestId("notes-session").textContent).toBe("session_1");
+
+ mockActiveSessionId = null;
+ view.rerender(
+ ,
+ );
+ expect(screen.getByTestId("notes-session").textContent).toBe("session_1");
+ });
+});
+
+describe("PluginTaskPanel failure containment", () => {
+ it("fails closed when a managed visibility predicate throws", () => {
+ function Notes() {
+ return secret panel
;
+ }
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ pluginRegistry.forPlugin("plugin-a").registerTaskPanel({
+ id: "notes",
+ title: "Notes",
+ Component: Notes,
+ visible: () => {
+ throw new Error("predicate failed");
+ },
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("secret panel")).toBeNull();
+ expect(screen.getByText("This panel is no longer available.")).not.toBeNull();
+ expect(consoleError).toHaveBeenCalledWith(
+ expect.stringContaining("plugin-a:notes"),
+ expect.any(Error),
+ );
+ consoleError.mockRestore();
+ });
+
it("renders a not-available fallback when the plugin is no longer registered (AC5)", () => {
render(
{
console.error = originalConsoleError;
});
});
+describe("PluginTaskPanel lease", () => {
+ it("revokes a panel navigation lease when visibility changes", () => {
+ let visible = true;
+ let openMessage: ((messageId: string) => { status: string }) | undefined;
+ const onOpenMessage = vi.fn(() => ({ status: "accepted" as const }));
+ function Notes(props: {
+ conversation: { openMessage: (messageId: string) => { status: string } };
+ }) {
+ openMessage = props.conversation.openMessage;
+ return visible panel
;
+ }
+ pluginRegistry.forPlugin("plugin-a").registerTaskPanel({
+ id: "notes",
+ title: "Notes",
+ Component: Notes,
+ mobileEnabled: true,
+ visible: () => visible,
+ });
+
+ const view = render(
+ ,
+ );
+ const initialOpenMessage = openMessage;
+ expect(initialOpenMessage?.("message-1").status).toBe("accepted");
+ expect(onOpenMessage).toHaveBeenCalledWith("message-1");
+
+ visible = false;
+ view.rerender(
+ ,
+ );
+
+ expect(screen.getByText("This panel is no longer available.")).not.toBeNull();
+ expect(initialOpenMessage?.("message-2").status).toBe("unavailable");
+ expect(onOpenMessage).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/components/task/plugin-task-panel.tsx b/apps/web/components/task/plugin-task-panel.tsx
index acb9f718d4..f7161f9cd7 100644
--- a/apps/web/components/task/plugin-task-panel.tsx
+++ b/apps/web/components/task/plugin-task-panel.tsx
@@ -1,10 +1,22 @@
"use client";
+import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { PluginErrorBoundary } from "@/components/plugins/plugin-error-boundary";
+import {
+ PluginConversationScopeProvider,
+ pluginConversationApi,
+} from "@/lib/plugins/conversation-host";
+import type { PluginTaskPanelRegistration } from "@/lib/plugins/registry-registration-types";
import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry";
-import type { PluginPresentation } from "@/lib/plugins/types";
+import type {
+ PluginOpenMessageResult,
+ PluginPresentation,
+ PluginTaskPanelContext,
+ PluginSessionKind,
+} from "@/lib/plugins/types";
+import { useDockviewStore } from "@/lib/state/dockview-store";
export interface PluginTaskPanelContainerProps {
pluginId: string;
@@ -12,6 +24,7 @@ export interface PluginTaskPanelContainerProps {
/** Full dockview/mobile panel id, e.g. `plugin::`. */
panelId: string;
presentation: PluginPresentation;
+ onOpenMessage?: (messageId: string) => PluginOpenMessageResult;
}
function PluginTaskPanelUnavailable() {
@@ -32,42 +45,120 @@ function PluginTaskPanelFailed() {
);
}
-/**
- * Resolves `{ pluginId, panelKey }` to its current `TaskPanelRegistration`
- * and renders the plugin's `Component` with `PluginTaskPanelProps`, wrapped
- * in a `PluginErrorBoundary` (AC6) so a throw inside the plugin's render
- * can't take down the surrounding dockview/mobile layout. Renders a
- * "no longer available" fallback — not a throw — when the plugin was
- * disabled/uninstalled after the panel was opened, or the layout it was
- * restored from references a panel of a plugin that is no longer installed
- * (AC5).
- */
+export function registrationIsVisible(
+ registration: PluginTaskPanelRegistration,
+ context: PluginTaskPanelContext,
+): boolean {
+ if (!registration.visible) return true;
+ try {
+ return registration.visible(context);
+ } catch (error) {
+ console.error(
+ `[plugins] visibility predicate for "${registration.pluginId}:${registration.id}" threw`,
+ error,
+ );
+ return false;
+ }
+}
+
+type RememberedSession = { taskId: string | null; sessionId: string };
+
+function useRememberedPanelSessionId(
+ taskId: string | null,
+ activeSessionId: string | null,
+): string | null {
+ const rememberedSession = useRef(null);
+ if (activeSessionId) {
+ rememberedSession.current = { taskId, sessionId: activeSessionId };
+ }
+ return (
+ activeSessionId ??
+ (rememberedSession.current?.taskId === taskId ? rememberedSession.current.sessionId : null)
+ );
+}
+
+/** Resolves and contains one plugin-contributed task panel. */
export function PluginTaskPanel({
pluginId,
panelKey,
panelId,
presentation,
+ onOpenMessage,
}: PluginTaskPanelContainerProps) {
- // Re-render when the registry changes (plugin disable/uninstall/reload) so
- // this panel picks up the fallback the instant its registration disappears.
usePluginRegistry();
+ const { t } = useTranslation();
const taskId = useAppStore((state) => state.tasks.activeTaskId);
- const sessionId = useAppStore((state) => state.tasks.activeSessionId);
+ const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
+ // Keep the deleted session identity until the host unmounts this panel.
+ const sessionId = useRememberedPanelSessionId(taskId, activeSessionId);
+ const session = useAppStore((state) =>
+ sessionId ? state.taskSessions?.items?.[sessionId] : undefined,
+ );
const registration = pluginRegistry.getTaskPanel(pluginId, panelKey);
+ const generation = pluginRegistry.getPluginLifecycle(pluginId)?.generation ?? 0;
+ let sessionKind: PluginSessionKind = null;
+ if (sessionId) {
+ sessionKind = session?.is_passthrough ? "passthrough" : "managed";
+ }
+ const context: PluginTaskPanelContext = {
+ taskId: taskId ?? "",
+ sessionId,
+ sessionKind,
+ presentation,
+ };
+ const registrationVisible = registration ? registrationIsVisible(registration, context) : false;
- if (!registration || !taskId) {
+ const lease = useMemo(
+ () => ({ active: true }),
+ [generation, panelId, panelKey, pluginId, presentation, registrationVisible, sessionId, taskId],
+ );
+ useEffect(
+ () => () => {
+ lease.active = false;
+ },
+ [lease],
+ );
+ const conversation = useMemo(
+ () => ({
+ history: pluginConversationApi,
+ openMessage(messageId: string): PluginOpenMessageResult {
+ if (!lease.active || !sessionId || messageId.trim() === "") {
+ return { status: "unavailable" };
+ }
+ if (presentation === "mobile") {
+ return onOpenMessage?.(messageId) ?? { status: "unavailable" };
+ }
+ const queued = useDockviewStore
+ .getState()
+ .scrollTranscriptToMessage(sessionId, messageId, session?.name || t("task:chat"));
+ return { status: queued ? "accepted" : "unavailable" };
+ },
+ }),
+ [lease, onOpenMessage, presentation, session?.name, sessionId, t],
+ );
+
+ if (!registration || !taskId || !registrationVisible) {
return ;
}
- const { Component } = registration;
return (
}>
-
+ >
+
+
);
}
diff --git a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx
index 95a84b4349..249486d774 100644
--- a/apps/web/components/task/task-chat-panel.scroll-target.test.tsx
+++ b/apps/web/components/task/task-chat-panel.scroll-target.test.tsx
@@ -57,12 +57,31 @@ vi.mock("@/hooks/domains/session/load-message-window", () => ({
}));
import {
+ isMessageRowRendered,
usePendingMessageScroll,
useScrollTargetConsumption,
type PendingMessageScrollTarget,
} from "./task-chat-panel";
+import type { RenderItem } from "@/hooks/use-processed-messages";
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
+describe("isMessageRowRendered", () => {
+ it("only treats direct message items as scrollable rows", () => {
+ const items = [
+ {
+ type: "turn_group",
+ id: "group-1",
+ turnId: "turn-1",
+ messages: [{ id: "grouped-prompt" }],
+ },
+ { type: "message", message: { id: "direct-prompt" } },
+ ] as unknown as RenderItem[];
+
+ expect(isMessageRowRendered(items, "grouped-prompt")).toBe(false);
+ expect(isMessageRowRendered(items, "direct-prompt")).toBe(true);
+ });
+});
+
let pendingFrames: Array<(() => void) | undefined> = [];
/** Runs two rounds of pending requestAnimationFrame callbacks inside act. */
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index ed5e930311..8fc4260c91 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -44,6 +44,8 @@ import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
import { findUnreadDividerItemId, lastRenderedMessageId } from "@/lib/session-unread-divider";
import { useSessionReadTracking } from "./chat/use-session-read-tracking";
import { useDrainOlderMessages } from "@/components/task/chat/use-drain-older-messages";
+import type { RenderItem } from "@/hooks/use-processed-messages";
+
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
import type { AppState } from "@/lib/state/store";
@@ -77,6 +79,10 @@ export type PendingMessageScrollTarget = {
token: number;
hostPanelId: string;
};
+/** Reports whether a target has a dedicated DOM row in the transcript. */
+export function isMessageRowRendered(items: readonly RenderItem[], messageId: string): boolean {
+ return items.some((item) => item.type === "message" && item.message.id === messageId);
+}
/** Scrolls a non-Dockview host target after the message row becomes rendered. */
type PendingMessageScrollOptions = {
@@ -1063,8 +1069,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
messageListRef,
isInitialMessagesLoading,
targetRendered: Boolean(
- dockviewTargetMessageId &&
- allMessages.some((message) => message.id === dockviewTargetMessageId),
+ dockviewTargetMessageId && isMessageRowRendered(groupedItems, dockviewTargetMessageId),
),
renderedMessageCount: allMessages.length,
});
@@ -1229,6 +1234,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
[{value,label,color}]) so a spec can drive multi-value
+ // membership, colors, and the untagged fallback against task ids the
+ // fixture cannot know ahead of time. `window.__e2eFacetNotify()` fires
+ // the subscription so the reactive re-read path is exercised for real.
+ var facetListeners = new Set();
+
+ function emit() {
+ listeners.forEach(function (fn) {
+ fn(moduleCount);
+ });
+ }
+
+ function incrementCount() {
+ moduleCount += 1;
+ emit();
+ }
+
+ function useCounter(React) {
+ var state = React.useState(moduleCount);
+ var count = state[0];
+ var setCount = state[1];
+ React.useEffect(function () {
+ setCount(moduleCount);
+ listeners.add(setCount);
+ return function () {
+ listeners.delete(setCount);
+ };
+ }, []);
+ return count;
+ }
+
+ var PROVIDER_ID = "fixture-source-control";
+ var PULL_REQUEST_URL =
+ "https://bitbucket.example.test/projects/TEAM/repos/fixture/pull-requests/42";
+ var REPOSITORY_URL = "https://bitbucket.example.test/scm/TEAM/fixture.git";
+
+ function fixtureRepository() {
+ return {
+ id: "fixture-repository",
+ repositoryId: "fixture-repository",
+ owner: "TEAM",
+ ownerOrProject: "TEAM",
+ name: "fixture",
+ repositoryName: "fixture",
+ fullName: "TEAM/fixture",
+ url: REPOSITORY_URL,
+ cloneUrl: REPOSITORY_URL,
+ providerHost: "bitbucket.example.test",
+ defaultBranch: "main",
+ private: true,
+ };
+ }
+
+ function abortableRefresh(signal) {
+ return new Promise(function (_resolve, reject) {
+ if (signal.aborted) {
+ reject(new Error("fixture review refresh aborted"));
+ return;
+ }
+ signal.addEventListener(
+ "abort",
+ function () {
+ reject(new Error("fixture review refresh aborted"));
+ },
+ { once: true },
+ );
+ });
+ }
+
+ window.registerKandevPlugin("kandev-plugin-e2e", {
+ initialize: function (registry, host) {
+ var React = host.React;
+ var jsx = host.jsx;
+ var ui = host.ui;
+
+ // Reads host.theme once on mount, then tracks it purely through
+ // host.onThemeChange — so the readout only stays correct if the
+ // subscription actually fires. Also counts notifications, proving the
+ // host does not re-notify on unrelated class churn.
+ function useHostTheme() {
+ var state = React.useState(function () {
+ return { theme: host.theme, changes: 0 };
+ });
+ var value = state[0];
+ var setValue = state[1];
+ React.useEffect(function () {
+ return host.onThemeChange(function (next) {
+ setValue(function (prev) {
+ return { theme: next, changes: prev.changes + 1 };
+ });
+ });
+ }, []);
+ return value;
+ }
+
+ function PluginPage() {
+ var count = useCounter(React);
+ var connectionState = React.useState("Not checked");
+ var connection = connectionState[0];
+ var setConnection = connectionState[1];
+ var themeState = useHostTheme();
+ function checkConnection() {
+ host.api
+ .invokeAction("connection-status", {
+ workspaceId: host.store.getState().workspaces.activeId || undefined,
+ })
+ .then(function (result) {
+ setConnection(
+ result.connected ? "Connected" : result.error || "Connection unavailable",
+ );
+ })
+ .catch(function (error) {
+ setConnection(error instanceof Error ? error.message : "Connection unavailable");
+ });
+ }
+ return jsx(
+ "div",
+ { id: "hello-plugin-page-root" },
+ jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"),
+ jsx("span", { id: "hello-task-counter" }, String(count)),
+ jsx(
+ "button",
+ {
+ id: "fixture-connection-status",
+ "data-testid": "fixture-connection-status",
+ type: "button",
+ onClick: checkConnection,
+ },
+ "Check Bitbucket connection",
+ ),
+ jsx(
+ "span",
+ { id: "fixture-connection-result", "data-testid": "fixture-connection-result" },
+ connection,
+ ),
+ jsx(
+ "span",
+ {
+ "data-testid": "hello-theme-readout",
+ "data-theme-changes": String(themeState.changes),
+ },
+ themeState.theme,
+ ),
+ // host.toast is a per-plugin Proxy over sonner; unit tests only ever
+ // see it against a mocked sonner, so this button is how an e2e
+ // proves a real toast renders and that .error does NOT file a
+ // frontend-error report.
+ jsx(
+ "button",
+ {
+ "data-testid": "hello-toast-error",
+ onClick: function () {
+ host.toast.error("Plugin toast error");
+ },
+ },
+ "toast error",
+ ),
+ );
+ }
+
+ function FixtureReviewPanel(props) {
+ return jsx(
+ "section",
+ {
+ "data-testid": "fixture-review-panel-" + props.presentation,
+ "data-review-key": props.reviewKey,
+ },
+ jsx(
+ "h2",
+ null,
+ "Bitbucket pull request #" + props.reviewKey.replace("pull-request-", ""),
+ ),
+ jsx("p", null, "Provider-neutral fixture review panel"),
+ );
+ }
+
+ function FixtureReviewSelector() {
+ return jsx("span", { "data-testid": "fixture-review-selector" }, "Bitbucket");
+ }
+
+ function openLinkResult(context) {
+ host.openTaskLinkDialog({
+ title: "Link Bitbucket pull request",
+ description: "Use a Bitbucket pull request URL for this task.",
+ inputLabel: "Pull request",
+ placeholder: PULL_REQUEST_URL,
+ emptyError: "Enter a Bitbucket pull request URL.",
+ failureMessage: "Failed to link Bitbucket pull request.",
+ successMessage: "Bitbucket pull request linked",
+ inputTestId: "fixture-link-pull-request-input",
+ errorTestId: "fixture-link-pull-request-error",
+ submitTestId: "fixture-link-pull-request-submit",
+ onSubmit: function (reference) {
+ return host.api
+ .invokeAction("link-pull-request", {
+ workspaceId: context.workspaceId,
+ taskId: context.taskId,
+ body: { pullRequestUrl: reference },
+ })
+ .then(function (result) {
+ if (!result.linked) {
+ throw new Error(result.error || "Connection unavailable");
+ }
+ });
+ },
+ });
+ return Promise.resolve();
+ }
+
+ function SidebarSlot() {
+ return jsx("div", { id: "hello-sidebar" }, "Hello E2E sidebar");
+ }
+
+ function MainTopBarSlot(props) {
+ var slotProps = props.slotProps || {};
+ var label = "Hello " + slotProps.currentPage;
+ return jsx(
+ ui.Button,
+ {
+ id: "hello-main-top-bar",
+ variant: "outline",
+ size: "icon-sm",
+ "aria-label": label,
+ },
+ jsx(
+ "svg",
+ {
+ className: "h-4 w-4",
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ "aria-hidden": "true",
+ },
+ jsx("path", { d: "M5 12h14M12 5l7 7l-7 7" }),
+ ),
+ jsx("span", { className: "sr-only" }, label),
+ );
+ }
+
+ // Debounce delay for the Notes panel's autosave — short, so e2e specs
+ // don't need to wait long for a write to reach host.storage.
+ var NOTES_SAVE_DEBOUNCE_MS = 150;
+
+ function useNotesValue(taskId, panelId) {
+ var state = React.useState("");
+ var value = state[0];
+ var setValue = state[1];
+ var loadedState = React.useState(false);
+ var loaded = loadedState[0];
+ var setLoaded = loadedState[1];
+
+ React.useEffect(
+ function () {
+ var cancelled = false;
+ // Each notification (subscribe's refresh callback, plus the initial
+ // call) starts its own host.storage.get. Two in flight at once can
+ // resolve out of order, so `cancelled` alone (unmount-only) isn't
+ // enough — an older read landing after a newer one would overwrite
+ // it. Only the read matching the current generation may commit.
+ var refreshGeneration = 0;
+ function refresh() {
+ var generation = ++refreshGeneration;
+ host.storage.get("task", taskId, "note").then(
+ function (entry) {
+ if (cancelled || generation !== refreshGeneration) return;
+ setValue(entry ? entry.value : "");
+ setLoaded(true);
+ },
+ function () {
+ if (cancelled || generation !== refreshGeneration) return;
+ setLoaded(true);
+ },
+ );
+ }
+ refresh();
+ // Scope echo suppression to this panel instance (panelId) rather
+ // than the host's shared tab-wide default writerId. The kanban
+ // "Enhance notes" action below writes this same note using that
+ // shared default (it has no ongoing subscription of its own to
+ // protect) — if this panel used the same default, the action's
+ // write would look like this panel's own echo and get silently
+ // dropped instead of refreshing the panel.
+ var unsubscribe = host.storage.subscribe(
+ { scope: "task", scopeId: taskId, key: "note", writerId: panelId },
+ refresh,
+ );
+ return function () {
+ cancelled = true;
+ unsubscribe();
+ };
+ },
+ [taskId, panelId],
+ );
+
+ return [value, setValue, loaded];
+ }
+
+ function NotesPanel(props) {
+ var taskId = props.taskId;
+ var panelId = props.panelId;
+ var notesValue = useNotesValue(taskId, panelId);
+ var value = notesValue[0];
+ var setValue = notesValue[1];
+ var loaded = notesValue[2];
+ var debounceRef = React.useRef(null);
+
+ function handleChange(e) {
+ var next = e.target.value;
+ setValue(next);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(function () {
+ host.storage
+ .set("task", taskId, "note", next, { writerId: panelId })
+ .catch(function () {
+ // surface the failed autosave instead of dropping it silently
+ });
+ }, NOTES_SAVE_DEBOUNCE_MS);
+ }
+
+ if (!loaded) {
+ return jsx("div", { "data-testid": "e2e-notes-panel-loading" }, "Loading notes…");
+ }
+ return jsx("textarea", {
+ "data-testid": "e2e-notes-panel",
+ "data-presentation": props.presentation,
+ value: value,
+ onChange: handleChange,
+ });
+ }
+
+ function promptDuration(startedAt, completedAt, translated) {
+ var started = Date.parse(startedAt);
+ var completed = Date.parse(completedAt);
+ if (Number.isNaN(started) || Number.isNaN(completed)) return "";
+ var seconds = Math.max(0, Math.floor((completed - started) / 1000));
+ var hours = Math.floor(seconds / 3600);
+ var minutes = Math.floor((seconds % 3600) / 60);
+ var remaining = seconds % 60;
+ if (hours > 0) {
+ return (
+ hours +
+ translated.t("durationHours") +
+ " " +
+ minutes +
+ translated.t("durationMinutes") +
+ " " +
+ remaining +
+ translated.t("durationSeconds")
+ );
+ }
+ if (minutes > 0) {
+ return (
+ minutes +
+ translated.t("durationMinutes") +
+ " " +
+ remaining +
+ translated.t("durationSeconds")
+ );
+ }
+ return remaining + translated.t("durationSeconds");
+ }
+
+ function PromptHistoryPluginRow(props) {
+ var message = props.message;
+ var turn = props.turn;
+ var translated = props.translated;
+ var duration =
+ turn && turn.completedAt
+ ? promptDuration(turn.startedAt, turn.completedAt, translated)
+ : "";
+ var favorite = props.conversation.history.useMessageFavorite(props.sessionId, message.id);
+ var isExpanded = props.expanded === message.id;
+ return jsx(
+ "article",
+ {
+ "data-testid": "fixture-prompt-history-row",
+ "data-message-id": message.id,
+ "data-favorite": favorite ? "true" : "false",
+ className: "border-b p-3",
+ },
+ jsx(
+ "button",
+ {
+ type: "button",
+ className: "min-h-11 w-full text-left",
+ onClick: function () {
+ props.setExpanded(isExpanded ? null : message.id);
+ },
+ },
+ translated.t("prompt", { values: { index: message.promptIndex || 0 } }),
+ message.senderTaskId ? " · " + translated.t("sentByAgent") : "",
+ duration
+ ? jsx("span", { "data-testid": "fixture-prompt-history-duration" }, " · " + duration)
+ : "",
+ ),
+ isExpanded
+ ? jsx(
+ "div",
+ { "data-testid": "fixture-prompt-history-detail" },
+ jsx(ui.PromptMentionText, { text: message.content, interactive: true }),
+ jsx(
+ "button",
+ {
+ type: "button",
+ className: "min-h-11",
+ onClick: function () {
+ props.conversation.openMessage(message.id);
+ },
+ },
+ translated.t("openPrompt"),
+ ),
+ )
+ : null,
+ );
+ }
+
+ function PromptHistoryPluginPanel(props) {
+ var translated = host.i18n.useTranslation();
+ var messages = props.conversation.history.useSessionMessages({
+ sessionId: props.sessionId,
+ taskId: props.taskId,
+ authorTypes: ["user"],
+ sort: "desc",
+ pageSize: 2,
+ });
+ var turns = props.conversation.history.useSessionTurns(props.sessionId, props.taskId);
+ var expandedState = React.useState(null);
+ var expanded = expandedState[0];
+ var setExpanded = expandedState[1];
+
+ if (messages.loading || turns.loading) {
+ return jsx(
+ "div",
+ { "data-testid": "fixture-prompt-history-loading" },
+ translated.t("loading"),
+ );
+ }
+ if (messages.error || turns.error) {
+ return jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "fixture-prompt-history-error",
+ onClick: function () {
+ messages.retry();
+ turns.retry();
+ },
+ },
+ translated.t("retry"),
+ );
+ }
+ if (messages.messages.length === 0) {
+ return jsx(
+ "div",
+ { "data-testid": "fixture-prompt-history-empty" },
+ translated.t("empty"),
+ );
+ }
+ var rows = messages.messages.map(function (message) {
+ var turn = turns.turns.find(function (candidate) {
+ return candidate.id === message.turnId;
+ });
+ return jsx(PromptHistoryPluginRow, {
+ key: message.id,
+ message: message,
+ turn: turn,
+ translated: translated,
+ conversation: props.conversation,
+ sessionId: props.sessionId,
+ expanded: expanded,
+ setExpanded: setExpanded,
+ });
+ });
+ if (messages.hasMore) {
+ rows.push(
+ jsx(
+ "button",
+ {
+ key: "load-more",
+ type: "button",
+ className: "min-h-11 w-full",
+ "data-testid": "fixture-prompt-history-load-more",
+ disabled: messages.loadingMore,
+ onClick: function () {
+ messages.loadMore();
+ },
+ },
+ translated.t("loadOlder"),
+ ),
+ );
+ }
+ return jsx(
+ "div",
+ {
+ "data-testid": "fixture-prompt-history-panel",
+ "data-removed": messages.removed ? "true" : "false",
+ "data-presentation": props.presentation,
+ className: "h-full min-w-0 overflow-y-auto overflow-x-hidden",
+ },
+ rows,
+ );
+ }
+
+ function CardIndicator(props) {
+ var slotProps = props.slotProps || {};
+ return jsx(
+ "span",
+ { "data-testid": "e2e-card-indicator", "data-task-id": slotProps.taskId },
+ "N",
+ );
+ }
+
+ function CardTags(props) {
+ var slotProps = props.slotProps || {};
+ return jsx(
+ "span",
+ { "data-testid": "e2e-card-tags", "data-task-id": slotProps.taskId },
+ "tags",
+ );
+ }
+
+ function RowMetadata(props) {
+ var slotProps = props.slotProps || {};
+ return jsx(
+ "span",
+ {
+ "data-testid": "e2e-row-metadata",
+ "data-task-id": slotProps.taskId,
+ "data-surface": slotProps.surface,
+ },
+ "fixture metadata",
+ );
+ }
+
+ function WorkspaceActionsSlot(props) {
+ var slotProps = props.slotProps || {};
+ return jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-sidebar-workspace-actions",
+ "data-workspace-id": slotProps.workspaceId,
+ "data-workspace-label": slotProps.workspaceLabel || "",
+ "data-presentation": slotProps.presentation || "unknown",
+ "aria-label": "Fixture workspace action",
+ className:
+ slotProps.presentation === "mobile"
+ ? "flex h-11 w-11 cursor-pointer items-center justify-center rounded-md border"
+ : "flex h-6 w-6 cursor-pointer items-center justify-center rounded-md border",
+ onClick: function (event) {
+ event.currentTarget.setAttribute("data-clicked", "true");
+ },
+ },
+ "W",
+ );
+ }
+
+ function StatusSlot(props) {
+ var slotProps = props.slotProps || {};
+ var id = slotProps.placement === "left" ? "hello-status-left" : "hello-status-right";
+ return jsx(
+ "span",
+ { id: id },
+ "Hello status " +
+ String(slotProps.presentation || "unknown") +
+ " " +
+ String(slotProps.activeTaskId || "no-task"),
+ );
+ }
+
+ // Drives PluginComposerCapability through native composers. Capturing
+ // the capability and using it after later renders mirrors a real voice
+ // integration whose recording completes asynchronously.
+ var COMPOSER_BUTTON_STYLE = {
+ fontSize: "9px",
+ lineHeight: "1",
+ padding: "2px 3px",
+ minWidth: "16px",
+ };
+
+ function ComposerAction(props) {
+ var slotProps = props.slotProps || {};
+ var composer = slotProps.composer;
+ var statusState = React.useState("");
+ var status = statusState[0];
+ var setStatus = statusState[1];
+ var capturedRef = React.useRef(null);
+
+ function record(result) {
+ setStatus(result && result.status ? result.status : String(result));
+ }
+
+ return jsx(
+ "span",
+ {
+ "data-testid": "e2e-composer-action",
+ "data-surface": String(slotProps.surface),
+ "data-presentation": String(slotProps.presentation),
+ "data-task-id": String(slotProps.taskId || ""),
+ "data-session-id": String(slotProps.activeSessionId || ""),
+ "data-disabled": String(Boolean(slotProps.disabled)),
+ "data-submittable": String(Boolean(slotProps.submittable)),
+ "data-status": status,
+ },
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-composer-insert",
+ style: COMPOSER_BUTTON_STYLE,
+ onClick: function () {
+ record(composer.insertText("DICTATED"));
+ },
+ },
+ "insert",
+ ),
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-composer-capture",
+ style: COMPOSER_BUTTON_STYLE,
+ onClick: function () {
+ capturedRef.current = composer;
+ setStatus("captured");
+ },
+ },
+ "capture",
+ ),
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-composer-insert-captured",
+ style: COMPOSER_BUTTON_STYLE,
+ onClick: function () {
+ record(
+ capturedRef.current
+ ? capturedRef.current.insertText("DICTATED")
+ : { status: "not-captured" },
+ );
+ },
+ },
+ "insert captured",
+ ),
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-composer-submit",
+ style: COMPOSER_BUTTON_STYLE,
+ onClick: function () {
+ var target = capturedRef.current || composer;
+ target.submit().then(record);
+ },
+ },
+ "submit",
+ ),
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "e2e-composer-focus",
+ style: COMPOSER_BUTTON_STYLE,
+ onClick: function () {
+ record(composer.focus());
+ },
+ },
+ "focus",
+ ),
+ );
+ }
+
+ function FixtureBitbucketIcon(props) {
+ return jsx(
+ "svg",
+ {
+ className: props.className,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ "aria-hidden": props["aria-hidden"] || true,
+ "data-testid": "fixture-bitbucket-icon",
+ },
+ jsx("path", { d: "M4 5h16l-2.5 14h-11z" }),
+ jsx("path", { d: "M9 9h6l-1 6h-4z" }),
+ );
+ }
+
+ registry.registerNavItem({
+ id: "e2e-hello",
+ label: "Hello E2E",
+ path: "/plugins/e2e-hello",
+ section: "main",
+ });
+ registry.registerNavItem({
+ id: "e2e-insights-tools",
+ label: "E2E Insights Tools",
+ path: "/plugins/e2e-hello",
+ section: "sidebar-footer",
+ });
+ // Three more sidebar-footer items so this one plugin install alone
+ // produces P = 4 (budget MAX_INLINE_PLUGIN_FOOTER_ITEMS = 3, plus one
+ // over-budget item) — enough to drive the desktop footer's overflow
+ // trigger and menu with the real Radix DropdownMenu in a browser (see
+ // plugins.spec.ts's overflow test). The budget counts destinations,
+ // not distinct plugins, so one plugin registering 4 items exercises
+ // the same partition as 4 plugins registering 1 each. Labeled
+ // "E2E Overflow Item N" rather than a numbered suffix of the first
+ // item's own label ("E2E Insights Tools") so Playwright's default
+ // substring name matching can't accidentally match more than one of
+ // these from an existing test written against the first item's label.
+ registry.registerNavItem({
+ id: "e2e-insights-tools-2",
+ label: "E2E Overflow Item 2",
+ path: "/plugins/e2e-hello",
+ section: "sidebar-footer",
+ });
+ registry.registerNavItem({
+ id: "e2e-insights-tools-3",
+ label: "E2E Overflow Item 3",
+ path: "/plugins/e2e-hello",
+ section: "sidebar-footer",
+ });
+ registry.registerNavItem({
+ id: "e2e-insights-tools-4",
+ label: "E2E Overflow Item 4",
+ path: "/plugins/e2e-hello",
+ section: "sidebar-footer",
+ });
+ registry.registerRoute("/plugins/e2e-hello", PluginPage);
+ registry.registerComponent("task-sidebar", SidebarSlot);
+ registry.registerComponent("main-top-bar", MainTopBarSlot);
+ registry.registerComponent("app-status-bar-left", StatusSlot);
+ registry.registerComponent("app-status-bar-right", StatusSlot);
+ registry.registerWsHandler("task.created", function () {
+ incrementCount();
+ });
+
+ registry.registerRepositoryProvider({
+ id: PROVIDER_ID,
+ label: "Bitbucket",
+ icon: FixtureBitbucketIcon,
+ listRepositories: function () {
+ return Promise.resolve([fixtureRepository()]);
+ },
+ matchesURL: function (url) {
+ if (typeof url !== "string") return false;
+ try {
+ return new URL(url).hostname === "bitbucket.example.test";
+ } catch (_error) {
+ return false;
+ }
+ },
+ listBranches: function (_context) {
+ return Promise.resolve([{ name: "main" }, { name: "feature/provider-contract" }]);
+ },
+ inspectURL: function (_context) {
+ return Promise.resolve({
+ providerId: PROVIDER_ID,
+ providerHost: "bitbucket.example.test",
+ ownerOrProject: "TEAM",
+ repositoryId: "fixture-repository",
+ repositoryName: "fixture",
+ cloneUrl: REPOSITORY_URL,
+ defaultBranch: "main",
+ baseBranch: "main",
+ headBranch: "feature/provider-contract",
+ pullRequest: { number: 42, title: "Provider-neutral contract" },
+ });
+ },
+ });
+
+ registry.registerTaskAction({
+ id: "link-bitbucket-pull-request",
+ label: "Bitbucket Pull Request",
+ icon: FixtureBitbucketIcon,
+ placement: "link",
+ group: "Link",
+ run: openLinkResult,
+ });
+
+ registry.registerReviewProvider({
+ id: PROVIDER_ID,
+ label: "Bitbucket",
+ icon: FixtureBitbucketIcon,
+ changeRequestNoun: "Pull Request",
+ order: 50,
+ getSnapshot: function (taskId) {
+ return [
+ {
+ providerId: PROVIDER_ID,
+ reviewKey: "pull-request-42",
+ title: "Bitbucket Pull Request #42",
+ url: PULL_REQUEST_URL,
+ connectionScope: "https://bitbucket.example.test",
+ repositoryId: "fixture-repository",
+ changeRequestNumber: 42,
+ state: "OPEN",
+ statusBadge: { label: "Open" },
+ taskId: taskId,
+ },
+ {
+ providerId: PROVIDER_ID,
+ reviewKey: "pull-request-43",
+ title: "Bitbucket Pull Request #43",
+ url: "https://bitbucket.example.test/projects/TEAM/repos/fixture/pull-requests/43",
+ connectionScope: "https://bitbucket.example.test",
+ repositoryId: "fixture-repository",
+ changeRequestNumber: 43,
+ state: "OPEN",
+ statusBadge: { label: "Open" },
+ taskId: taskId,
+ },
+ ];
+ },
+ subscribe: function () {
+ return function () {};
+ },
+ refresh: function (_taskId, signal) {
+ return abortableRefresh(signal);
+ },
+ ReviewPanel: FixtureReviewPanel,
+ Selector: FixtureReviewSelector,
+ });
+
+ registry.registerTranslations({
+ en: {
+ promptHistoryTitle: "Prompt history fixture",
+ loading: "Loading prompt history",
+ retry: "Retry",
+ empty: "No prompts yet",
+ prompt: "Prompt {{index}}",
+ sentByAgent: "Sent by agent",
+ openPrompt: "Open prompt",
+ loadOlder: "Load older prompts",
+ durationSeconds: "s",
+ durationMinutes: "m",
+ durationHours: "h",
+ },
+ pseudo: {
+ promptHistoryTitle: "Ƥřǿɱƥŧ ħīşŧǿřẏ ƒīẋŧŭřḗ",
+ loading: "Ŀǿȧḓīƞɠ ƥřǿɱƥŧ ħīşŧǿřẏ",
+ retry: "Řḗŧřẏ",
+ empty: "Ƞǿ ƥřǿɱƥŧş ẏḗŧ",
+ prompt: "Ƥřǿɱƥŧ {{index}}",
+ sentByAgent: "Şḗƞŧ ƀẏ ȧɠḗƞŧ",
+ openPrompt: "Ǿƥḗƞ ƥřǿɱƥŧ",
+ loadOlder: "Ŀǿȧḓ ǿŀḓḗř ƥřǿɱƥŧş",
+ durationSeconds: "ş",
+ durationMinutes: "ɱ",
+ durationHours: "ħ",
+ },
+ });
+ registry.registerTaskPanel({
+ id: "prompt-history-plugin",
+ title: "Prompt history fixture",
+ titleKey: "promptHistoryTitle",
+ icon: "history",
+ Component: PromptHistoryPluginPanel,
+ mobileEnabled: true,
+ visible: function (context) {
+ return context.sessionKind === "managed";
+ },
+ });
+
+ registry.registerTaskPanel({
+ id: "notes",
+ title: "Notes",
+ icon: "book",
+ Component: NotesPanel,
+ mobileEnabled: true,
+ });
+ registry.registerTaskListFacet({
+ id: "fixture-tags",
+ label: "Fixture tag",
+ getValues: function (context) {
+ var byTask = window.__e2eFacetValues || {};
+ if (byTask.__throwFor === context.taskId) throw new Error("fixture facet boom");
+ return byTask[context.taskId] || [];
+ },
+ subscribe: function (listener) {
+ facetListeners.add(listener);
+ window.__e2eFacetNotify = function () {
+ facetListeners.forEach(function (fn) {
+ fn();
+ });
+ };
+ return function () {
+ facetListeners.delete(listener);
+ };
+ },
+ });
+ registry.registerComponent("chat-input-actions", ComposerAction);
+ registry.registerComponent("task-create-input-actions", ComposerAction);
+ registry.registerComponent("new-session-input-actions", ComposerAction);
+ registry.registerComponent("task-card-indicators", CardIndicator);
+ registry.registerComponent("task-card-tags", CardTags);
+ registry.registerComponent("task-row-metadata", RowMetadata);
+ registry.registerComponent("sidebar-workspace-actions", WorkspaceActionsSlot);
+ registry.registerTaskMenuAction({
+ id: "enhance-notes",
+ label: "Enhance notes",
+ group: "edit",
+ run: function (context) {
+ return host.storage
+ .set("task", context.taskId, "note", "Enhanced via plugin action")
+ .then(function () {
+ // Keep the received responsive context observable to the E2E
+ // fixture without changing the plugin-facing action contract.
+ return host.storage.set(
+ "task",
+ context.taskId,
+ "menu-presentation",
+ context.presentation,
+ );
+ });
+ },
+ });
+ registry.registerTaskMenuAction({
+ id: "inspect-task-metadata",
+ label: "Inspect task metadata",
+ group: "primary",
+ run: function (context) {
+ return host.storage.set(
+ "task",
+ context.taskId,
+ "primary-menu-presentation",
+ context.presentation,
+ );
+ },
+ });
+
+ registry.registerKeybinding("open-demo", function () {
+ function DemoModalContent() {
+ var completedState = React.useState(false);
+ var completed = completedState[0];
+ var setCompleted = completedState[1];
+ var longRows = [];
+ for (var index = 0; index < 32; index += 1) {
+ longRows.push(
+ jsx(
+ "p",
+ { key: "long-modal-row-" + index },
+ "Long plugin modal content row " +
+ String(index + 1) +
+ " keeps the opaque plugin surface growing beyond the viewport.",
+ ),
+ );
+ }
+
+ // The Tooltip is the point of this modal in e2e: PluginModalHost
+ // mounts outside AppShell, so without its own TooltipProvider a
+ // Tooltip here throws on render and the error boundary swallows the
+ // whole modal. jsdom cannot open a Radix tooltip from synthetic
+ // hover, so real pointer hover is only assertable here.
+ return jsx(
+ "div",
+ {
+ id: "hello-demo-modal",
+ "data-testid": "hello-demo-modal",
+ style: { display: "grid", gap: "8px" },
+ },
+ "Hello from the plugin modal",
+ jsx(
+ ui.Tooltip,
+ null,
+ jsx(ui.TooltipTrigger, { "data-testid": "hello-modal-tooltip-trigger" }, "hover me"),
+ jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"),
+ ),
+ jsx("div", { "data-testid": "hello-long-modal-content" }, longRows),
+ jsx(
+ "button",
+ {
+ type: "button",
+ "data-testid": "hello-long-modal-final-action",
+ style: { minHeight: "44px", padding: "8px 12px" },
+ onClick: function () {
+ setCompleted(true);
+ },
+ },
+ completed ? "Plugin modal action complete" : "Complete plugin modal action",
+ ),
+ );
+ }
+ host.openModal({
+ title: "Demo Modal",
+ content: DemoModalContent,
+ size: "md",
+ });
+ });
+ },
+ });
+})();
diff --git a/apps/web/e2e/global-setup.test.ts b/apps/web/e2e/global-setup.test.ts
index 52649deca9..db5d97f5ed 100644
--- a/apps/web/e2e/global-setup.test.ts
+++ b/apps/web/e2e/global-setup.test.ts
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import globalSetup, { assertBackendArtifactsFresh, isContainerRun } from "./global-setup";
+import { assertBackendArtifactsFresh, isContainerRun, runGlobalSetup } from "./global-setup";
let backendDir: string;
let binPath: string;
@@ -182,6 +182,20 @@ describe("assertBackendBinaryFresh", () => {
expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow();
});
+ it("ignores generated plugin fixture UI rebuilt after the backend binary", () => {
+ touch(
+ path.join(backendDir, "internal", "task", "service.go"),
+ new Date("2025-01-01T00:00:00Z"),
+ );
+ touch(binPath, new Date("2026-01-01T00:00:00Z"));
+ const generated = path.join(backendDir, "cmd", "plugin-fixture", "fixture-package", "ui");
+ fs.mkdirSync(generated, { recursive: true });
+ fs.writeFileSync(path.join(generated, "bundle.js"), "generated");
+ touch(path.join(generated, "bundle.js"), new Date("2026-06-01T00:00:00Z"));
+
+ expect(() => assertBackendBinaryFresh(backendDir, [binPath], {})).not.toThrow();
+ });
+
it("ignores Go coverage and compiled-test artifacts left in the source tree", () => {
// `make -C apps/backend test-coverage` writes these next to go.mod. They are
// gitignored build outputs, so a test run must not demand a backend rebuild.
@@ -345,7 +359,7 @@ describe("globalSetup", () => {
vi.stubEnv("KANDEV_E2E_SKIP_FRESHNESS", "1");
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
- (globalSetup as () => void)();
+ runGlobalSetup({ verifyPluginFixtureIdentity: false });
expect(existsSync).toHaveBeenCalledWith(customBackend);
expect(existsSync).not.toHaveBeenCalledWith(path.join(backendDirForTest(), "bin", "kandev"));
@@ -358,7 +372,9 @@ describe("globalSetup", () => {
.spyOn(fs, "existsSync")
.mockImplementation((artifactPath) => artifactPath !== customBackend);
- expect(() => (globalSetup as () => void)()).toThrow(/KANDEV_E2E_BIN file does not exist/);
+ expect(() => runGlobalSetup({ verifyPluginFixtureIdentity: false })).toThrow(
+ /KANDEV_E2E_BIN file does not exist/,
+ );
expect(existsSync).toHaveBeenCalledWith(customBackend);
});
@@ -376,7 +392,7 @@ describe("globalSetup", () => {
vi.stubEnv(marker, value);
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
- (globalSetup as () => void)();
+ runGlobalSetup({ verifyPluginFixtureIdentity: false });
expect(existsSync).toHaveBeenCalledWith(
path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"),
@@ -393,7 +409,7 @@ describe("globalSetup", () => {
process.argv = [...originalArgv, "--project=containers"];
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
- (globalSetup as () => void)();
+ runGlobalSetup({ verifyPluginFixtureIdentity: false });
expect(existsSync).toHaveBeenCalledWith(
path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"),
@@ -410,7 +426,7 @@ describe("globalSetup", () => {
process.argv = [...originalArgv, "--project=kubernetes-compat"];
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
- (globalSetup as () => void)();
+ runGlobalSetup({ verifyPluginFixtureIdentity: false });
expect(existsSync).toHaveBeenCalledWith(
path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"),
@@ -426,7 +442,7 @@ describe("globalSetup", () => {
vi.stubEnv("KANDEV_E2E_DOCKER", "");
const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(true);
- (globalSetup as () => void)();
+ runGlobalSetup({ verifyPluginFixtureIdentity: false });
expect(existsSync).not.toHaveBeenCalledWith(
path.join(backendDirForTest(), "bin", "mock-agent-linux-amd64"),
diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts
index d45095a26c..0505a5ae99 100644
--- a/apps/web/e2e/global-setup.ts
+++ b/apps/web/e2e/global-setup.ts
@@ -10,7 +10,15 @@ export type BackendArtifact = {
rebuildTarget: string;
};
+export type GlobalSetupOptions = {
+ verifyPluginFixtureIdentity?: boolean;
+};
+
export default function globalSetup() {
+ runGlobalSetup();
+}
+
+export function runGlobalSetup(options: GlobalSetupOptions = {}): void {
const customKandevBin = process.env.KANDEV_E2E_BIN;
const kandevBin = customKandevBin ?? path.join(BACKEND_DIR, "bin", "kandev");
const artifacts: BackendArtifact[] = [
@@ -44,6 +52,11 @@ export default function globalSetup() {
assertArtifactExists(artifact.path, artifact.rebuildTarget);
}
assertBackendArtifactsFresh(BACKEND_DIR, artifacts);
+ const pluginIdentityPath = path.join(BACKEND_DIR, ".build", "e2e-plugin-identity.json");
+ assertArtifactExists(pluginIdentityPath, "e2e-plugin-package");
+ if (options.verifyPluginFixtureIdentity !== false) {
+ assertPluginFixtureIdentity(pluginIdentityPath);
+ }
const spaIndex = path.join(WEB_DIR, "dist", "index.html");
if (!fs.existsSync(spaIndex)) {
@@ -136,6 +149,7 @@ const BACKEND_SOURCE_SKIP_FILE_PATTERNS = [
* pointless backend rebuild over a bundle the tests ignore.
*/
const EMBEDDED_WEB_DIR = path.join("internal", "webapp", "embedded", "generated");
+const GENERATED_PLUGIN_FIXTURE_UI_DIR = path.join("cmd", "plugin-fixture", "fixture-package", "ui");
/**
* Every file counts, not just `*.go`: the binary `//go:embed`s a large asset
@@ -160,6 +174,7 @@ function findNewestBackendSource(
if (entry.isDirectory()) {
if (BACKEND_SOURCE_SKIP_DIRS.has(entry.name)) continue;
if (path.relative(backendDir, entryPath) === EMBEDDED_WEB_DIR) continue;
+ if (path.relative(backendDir, entryPath) === GENERATED_PLUGIN_FIXTURE_UI_DIR) continue;
walk(entryPath);
continue;
}
@@ -276,6 +291,73 @@ function validateE2EBuildIdentity(
return true;
}
+type PluginFixtureIdentity = {
+ schemaVersion: number;
+ pluginId: string;
+ pluginVersion: string;
+ bundlePath: string;
+ sourceHash: string;
+ generatedOutputHash: string;
+ manifestHash: string;
+ archiveSha256: string;
+ manifestCapability: string;
+ minKandevVersion: string;
+ panelKey: string;
+};
+
+function assertPluginFixtureIdentity(identityPath: string): void {
+ const identity = JSON.parse(fs.readFileSync(identityPath, "utf8")) as PluginFixtureIdentity;
+ if (
+ identity.schemaVersion !== 2 ||
+ identity.pluginId !== "kandev-plugin-e2e" ||
+ identity.pluginVersion !== "1.0.0" ||
+ identity.bundlePath !== "ui/bundle.js" ||
+ identity.manifestCapability !== "messages" ||
+ identity.minKandevVersion !== "0.91.1" ||
+ identity.panelKey !== "prompt-history-plugin"
+ ) {
+ throw new Error(`Unsupported E2E plugin identity schema in ${identityPath}`);
+ }
+ const source = path.join(
+ WEB_DIR,
+ "e2e",
+ "fixtures",
+ "plugins",
+ "prompt-history-plugin",
+ "bundle.js",
+ );
+ const output = path.join(
+ BACKEND_DIR,
+ "cmd",
+ "plugin-fixture",
+ "fixture-package",
+ "ui",
+ "bundle.js",
+ );
+ const manifest = path.join(
+ BACKEND_DIR,
+ "cmd",
+ "plugin-fixture",
+ "fixture-package",
+ "manifest.yaml",
+ );
+ const archive = path.join(BACKEND_DIR, ".build", "kandev-plugin-e2e-1.0.0.tar.gz");
+ for (const [file, expected] of [
+ [source, identity.sourceHash],
+ [output, identity.generatedOutputHash],
+ [manifest, identity.manifestHash],
+ [archive, identity.archiveSha256],
+ ]) {
+ assertArtifactExists(file);
+ const actual = sha256File(file);
+ if (actual !== expected) {
+ throw new Error(
+ `E2E plugin identity checksum mismatch for ${file}: got ${actual}, expected ${expected}`,
+ );
+ }
+ }
+}
+
/**
* Fail fast when the coverage oracle is asked to run against a bundle that has
* no pseudo catalog.
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index 1ee3c51775..af83f20727 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -1519,7 +1519,7 @@ export class ApiClient {
turnStartedAt?: string;
turnCompletedAt?: string;
},
- ): Promise {
+ ): Promise<{ messageId: string; turnId: string | null }> {
const body: Record = { session_id: sessionId, type: opts.type };
if (opts.content !== undefined) body.content = opts.content;
if (opts.metadata !== undefined) body.metadata = opts.metadata;
@@ -1529,7 +1529,24 @@ export class ApiClient {
if (opts.newTurn !== undefined) body.new_turn = opts.newTurn;
if (opts.turnStartedAt !== undefined) body.turn_started_at = opts.turnStartedAt;
if (opts.turnCompletedAt !== undefined) body.turn_completed_at = opts.turnCompletedAt;
- await this.request("POST", "/api/v1/_test/messages", body);
+ const result = await this.request<{ message_id: string; turn_id?: string | null }>(
+ "POST",
+ "/api/v1/_test/messages",
+ body,
+ );
+ return { messageId: result.message_id, turnId: result.turn_id ?? null };
+ }
+
+ async updateSessionMessage(messageId: string, content: string): Promise {
+ await this.request("PATCH", `/api/v1/_test/messages/${messageId}`, { content });
+ }
+
+ async deleteSessionMessage(messageId: string): Promise {
+ await this.request("DELETE", `/api/v1/_test/messages/${messageId}`);
+ }
+
+ async completeSessionTurn(turnId: string): Promise {
+ await this.request("POST", `/api/v1/_test/turns/${turnId}/complete`);
}
async seedToolCallMessages(
@@ -2599,7 +2616,7 @@ export class ApiClient {
}
async deleteSession(sessionId: string): Promise {
- await this.request("DELETE", `/api/v1/task-sessions/${sessionId}`);
+ await this.request("DELETE", `/api/v1/_test/task-sessions/${sessionId}`);
}
async getTask(taskId: string): Promise<{
diff --git a/apps/web/e2e/helpers/causal-waits.test.ts b/apps/web/e2e/helpers/causal-waits.test.ts
index f9823901c9..a075677c9a 100644
--- a/apps/web/e2e/helpers/causal-waits.test.ts
+++ b/apps/web/e2e/helpers/causal-waits.test.ts
@@ -150,6 +150,23 @@ describe("watchWs().waitForEvent", () => {
payload: { task_id: "t1", status: "cancelled" },
});
});
+ it("resolves on an ordered session event using its legacy action alias", async () => {
+ const { page, fake } = fakePage();
+ const ws = watchWs(page);
+ const socket = fake.openSocket(GATEWAY);
+ const pending = ws.waitForEvent("session.message.added", {
+ where: (payload) => payload.session_id === "session-1",
+ });
+ socket.emit("framereceived", {
+ type: "session.event",
+ event_type: "message.added",
+ payload: { session_id: "session-1", message_id: "message-1" },
+ });
+ await expect(pending).resolves.toMatchObject({
+ eventType: "message.added",
+ payload: { session_id: "session-1", message_id: "message-1" },
+ });
+ });
it("skips notifications that fail the `where` predicate", async () => {
const { page, fake } = fakePage();
diff --git a/apps/web/e2e/helpers/causal-waits.ts b/apps/web/e2e/helpers/causal-waits.ts
index 2b9cb36912..60f49310c5 100644
--- a/apps/web/e2e/helpers/causal-waits.ts
+++ b/apps/web/e2e/helpers/causal-waits.ts
@@ -124,6 +124,7 @@ export type WsFrame = {
id?: string;
type?: string;
action?: string;
+ eventType?: string;
payload: Record;
};
@@ -160,6 +161,7 @@ function decodeFrame(payload: string | Buffer | Uint8Array): WsFrame | null {
id: typeof parsed.id === "string" ? parsed.id : undefined,
type: typeof parsed.type === "string" ? parsed.type : undefined,
action: typeof parsed.action === "string" ? parsed.action : undefined,
+ eventType: typeof parsed.event_type === "string" ? parsed.event_type : undefined,
payload: body && typeof body === "object" ? (body as Record) : {},
};
} catch {
@@ -214,6 +216,12 @@ export function watchWs(page: Page): WsWatcher {
};
}
+function matchesEventAction(frame: WsFrame, action: string): boolean {
+ if (frame.action === action) return true;
+ if (frame.type !== "session.event") return false;
+ const orderedEventType = action.startsWith("session.") ? action.slice("session.".length) : action;
+ return frame.eventType === action || frame.eventType === orderedEventType;
+}
function waitForEvent(
channels: Channels,
action: string,
@@ -227,14 +235,9 @@ function waitForEvent(
reject,
);
wait.listen(channels.received, (frame) => {
- if (frame.action !== action) return;
- // Strict, not `frame.type && ...`: a frame carrying the right `action` but
- // no `type` is not a confirmed server push, and resolving on one would be
- // the exact false-positive this module exists to remove. Matches the
- // sibling guard in `waitForResponse`. Observed gateway traffic only ever
- // carries `notification` / `response` / `request`, so nothing legitimate
- // relies on the loose form.
- if (frame.type !== "notification") return;
+ if (!matchesEventAction(frame, action)) return;
+ // Strictly require a server-push frame, including ordered session events.
+ if (frame.type !== "notification" && frame.type !== "session.event") return;
if (where && !where(frame.payload)) return;
wait.dispose();
resolve(frame);
diff --git a/apps/web/e2e/helpers/plugin-fixture.ts b/apps/web/e2e/helpers/plugin-fixture.ts
index a72a470c28..9a09326015 100644
--- a/apps/web/e2e/helpers/plugin-fixture.ts
+++ b/apps/web/e2e/helpers/plugin-fixture.ts
@@ -1,5 +1,6 @@
import path from "node:path";
import { type Page, expect } from "@playwright/test";
+import type { ApiClient } from "./api-client";
export const PLUGIN_ID = "kandev-plugin-e2e";
export const PACKAGE_PATH = path.resolve(
@@ -7,6 +8,10 @@ export const PACKAGE_PATH = path.resolve(
"../../../backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
+export async function uninstallFixturePlugin(apiClient: ApiClient): Promise {
+ await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
+}
+
export async function installFixturePlugin(page: Page): Promise {
await page.goto("/settings/plugins");
await page.getByTestId("install-plugin-trigger").click();
diff --git a/apps/web/e2e/helpers/session-stream-overload.ts b/apps/web/e2e/helpers/session-stream-overload.ts
index c362b2ff3a..65bea0e944 100644
--- a/apps/web/e2e/helpers/session-stream-overload.ts
+++ b/apps/web/e2e/helpers/session-stream-overload.ts
@@ -60,7 +60,10 @@ export function noisyReceivedFrames(
frame.sessionId === sessionId &&
// A subscriber present before the burst can receive its coalesced state
// as an add; a subscriber joining mid-burst observes subsequent updates.
- (frame.action === "session.message.added" || frame.action === "session.message.updated"),
+ (frame.action === "session.message.added" ||
+ frame.action === "session.message.updated" ||
+ (frame.type === "session.event" &&
+ (frame.eventType === "message.added" || frame.eventType === "message.updated"))),
);
}
diff --git a/apps/web/e2e/helpers/ws-drop.ts b/apps/web/e2e/helpers/ws-drop.ts
index 853a80c959..2aeee69ad8 100644
--- a/apps/web/e2e/helpers/ws-drop.ts
+++ b/apps/web/e2e/helpers/ws-drop.ts
@@ -16,6 +16,12 @@ type MessageAddResponseDropController = {
droppedCount: () => number;
};
+type ExpiredPluginSnapshotController = {
+ expireNextPluginSnapshot: () => void;
+ modifiedCount: () => number;
+ pluginSubscribeCount: () => number;
+};
+
export type QueueAdmissionDropController = {
dropNextQueueAddRequest: () => void;
dropNextQueueAddResponse: (count?: number) => void;
@@ -44,18 +50,34 @@ function parseJSONFrames(message: string | Buffer): Array | null {
+ const envelope = asRecord(message);
+ const payload = asRecord(envelope?.payload);
+ const isLegacy = envelope?.action === "session.message.added" && payload?.author_type === "user";
+ const isOrdered =
+ envelope?.type === "session.event" &&
+ envelope?.event_type === "message.added" &&
+ payload?.author_type === "user";
+ if (
+ (!isLegacy && !isOrdered) ||
+ typeof payload?.content !== "string" ||
+ !payload.content.includes(prompt)
+ ) {
+ return null;
+ }
+ return payload;
+}
+
function isTargetUserMessageAdded(
message: unknown,
prompt: string,
): message is { payload: { content: string } } {
+ return targetMessagePayload(message, prompt) !== null;
+}
+
+function targetAction(message: unknown): string {
const envelope = asRecord(message);
- const payload = asRecord(envelope?.payload);
- return (
- envelope?.action === "session.message.added" &&
- payload?.author_type === "user" &&
- typeof payload.content === "string" &&
- payload.content.includes(prompt)
- );
+ return envelope?.type === "session.event" ? "session.event" : "session.message.added";
}
function filterServerFrame(
@@ -77,7 +99,7 @@ function filterServerFrame(
const parsed = JSON.parse(trimmed) as unknown;
if (isTargetUserMessageAdded(parsed, prompt)) {
didDrop = true;
- dropped.push({ action: "session.message.added", content: parsed.payload.content });
+ dropped.push({ action: targetAction(parsed), content: parsed.payload.content });
continue;
}
} catch {
@@ -95,12 +117,14 @@ export async function routeMainWebSocketWithPromptDrop(page: Page): Promise();
+ const orderedRecoveryRequestIDs = new Set();
let recoveryResponses = 0;
await page.routeWebSocket(/\/ws$/, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
for (const frame of parseJSONFrames(message)) {
+ const payload = asRecord(frame.payload);
if (
promptToDrop !== null &&
frame.type === "request" &&
@@ -109,6 +133,16 @@ export async function routeMainWebSocketWithPromptDrop(page: Page): Promise dropped.length,
@@ -364,3 +407,85 @@ export async function routeMainWebSocketWithQueueAdmissionDrops(
droppedResponseCount: () => state.droppedResponses.value,
};
}
+
+/**
+ * Rewrites one plugin subscription expiry in the browser transport. The
+ * signed token remains server-valid, so the panel must exercise its normal
+ * fresh rebind path instead of relying on a relaxed backend validation rule.
+ */
+export async function routeMainWebSocketWithExpiredPluginSnapshot(
+ page: Page,
+): Promise {
+ const requestIDs = new Set();
+ let armed = false;
+ let modified = 0;
+ let pluginSubscribeRequests = 0;
+
+ await page.routeWebSocket(/\/ws$/, (ws) => {
+ const server = ws.connectToServer();
+ ws.onMessage((message) => {
+ for (const frame of parseJSONFrames(message)) {
+ const payload = asRecord(frame.payload);
+ if (
+ frame.type === "request" &&
+ frame.action === "session.subscribe" &&
+ typeof frame.id === "string" &&
+ payload?.consumer_kind === "plugin"
+ ) {
+ pluginSubscribeRequests += 1;
+ if (armed) requestIDs.add(frame.id);
+ }
+ }
+ server.send(message);
+ });
+ server.onMessage((message) => {
+ if (typeof message !== "string") {
+ ws.send(message);
+ return;
+ }
+ const rewritten: string[] = [];
+ let didRewrite = false;
+ for (const part of message.split("\n")) {
+ const trimmed = part.trim();
+ if (!trimmed) {
+ rewritten.push(part);
+ continue;
+ }
+ let frame: Record | null = null;
+ try {
+ frame = asRecord(JSON.parse(trimmed));
+ } catch {
+ // Preserve non-JSON frames.
+ }
+ if (
+ armed &&
+ frame?.type === "response" &&
+ frame.action === "session.subscribe" &&
+ typeof frame.id === "string" &&
+ requestIDs.delete(frame.id)
+ ) {
+ const payload = asRecord(frame.payload);
+ if (payload?.success === true) {
+ frame.payload = { ...payload, expires_at: "2000-01-01T00:00:00Z" };
+ rewritten.push(JSON.stringify(frame));
+ armed = false;
+ modified += 1;
+ didRewrite = true;
+ continue;
+ }
+ }
+ rewritten.push(part);
+ }
+ ws.send(didRewrite ? rewritten.join("\n") : message);
+ });
+ });
+
+ return {
+ expireNextPluginSnapshot: () => {
+ requestIDs.clear();
+ armed = true;
+ },
+ modifiedCount: () => modified,
+ pluginSubscribeCount: () => pluginSubscribeRequests,
+ };
+}
diff --git a/apps/web/e2e/helpers/ws-traffic.ts b/apps/web/e2e/helpers/ws-traffic.ts
index 1d0892dd00..a8e661b81c 100644
--- a/apps/web/e2e/helpers/ws-traffic.ts
+++ b/apps/web/e2e/helpers/ws-traffic.ts
@@ -4,6 +4,7 @@ export type GatewayTrafficFrame = {
direction: "sent" | "received";
action?: string;
type?: string;
+ eventType?: string;
sessionId?: string;
taskId?: string;
bytes: number;
@@ -74,6 +75,7 @@ function frameFromPayload(
return {
direction,
action: stringField(message?.action),
+ eventType: stringField((message as Record | null)?.event_type),
type: stringField(message?.type),
sessionId:
stringField(messagePayload?.session_id) ??
diff --git a/apps/web/e2e/scripts/run-e2e.sh b/apps/web/e2e/scripts/run-e2e.sh
index ebefac5b4b..c39120b262 100755
--- a/apps/web/e2e/scripts/run-e2e.sh
+++ b/apps/web/e2e/scripts/run-e2e.sh
@@ -167,15 +167,12 @@ build_backend_host() {
make -C "$BACKEND_DIR" "${targets[@]}" >/dev/null || die "backend build failed"
}
-# Packages the plugin-fixture SDK plugin (cmd/plugin-fixture) into
-# .build/kandev-plugin-e2e-1.0.0.tar.gz for tests/plugins/plugins.spec.ts.
-# Always built on the host — like mock-agent, e2e/global-setup.ts only checks
-# this file exists (not that it's current), so it must be rebuilt any time
-# cmd/plugin-fixture changes (this runner always rebuilds it via build_fe's
-# sibling call below, unless --no-build is set).
+# Builds the external-style fixture UI from its sole source, then packages the
+# backend and UI with their immutable identity for every host/container run.
build_plugin_package() {
- log "packaging e2e fixture plugin"
- make -C "$BACKEND_DIR" e2e-plugin-package >/dev/null || die "e2e plugin package build failed"
+ log "building and packaging e2e fixture plugin"
+ make -C "$BACKEND_DIR" e2e-plugin-ui e2e-plugin-package >/dev/null \
+ || die "e2e plugin package build failed"
}
backend_runs_in() { # $1=image — does the host-built binary run there? (glibc check, ~2s)
diff --git a/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts b/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts
new file mode 100644
index 0000000000..24137f6c1f
--- /dev/null
+++ b/apps/web/e2e/tests/plugins/conversation-recovery.spec.ts
@@ -0,0 +1,190 @@
+import { expect, test } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import {
+ routeMainWebSocketWithExpiredPluginSnapshot,
+ routeMainWebSocketWithPromptDrop,
+} from "../../helpers/ws-drop";
+import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
+import { SessionPage } from "../../pages/session-page";
+
+const PANEL_KEY = "prompt-history-plugin";
+
+async function createSeededConversation(
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+): Promise<{ taskId: string; sessionId: string }> {
+ const task = await apiClient.createTask(seedData.workspaceId, title, {
+ description: "conversation recovery fixture",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "IDLE",
+ agentProfileId: seedData.agentProfileId,
+ repositoryId: seedData.repositoryId,
+ });
+ return { taskId: task.id, sessionId };
+}
+
+async function uninstallFixturePlugin(apiClient: ApiClient): Promise {
+ await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
+}
+
+test.describe("Conversation recovery", () => {
+ test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient));
+
+ test("repairs core messages and turns after a multi-event gap on the live socket", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const wsDrop = await routeMainWebSocketWithPromptDrop(testPage);
+ let gatewayCloseCount = 0;
+ testPage.on("websocket", (socket) => {
+ if (!socket.url().endsWith("/ws")) return;
+ socket.on("close", () => {
+ gatewayCloseCount += 1;
+ });
+ });
+
+ const { taskId, sessionId } = await createSeededConversation(
+ apiClient,
+ seedData,
+ "Core conversation recovery",
+ );
+ await testPage.goto(`/t/${taskId}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 30_000 });
+
+ const skippedPrompt = "core recovery skipped prompt";
+ const replayedPrompt = "core recovery replayed prompt";
+ wsDrop.dropPrompt(skippedPrompt);
+ const skipped = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: skippedPrompt,
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-14T12:00:00Z",
+ turnCompletedAt: "2026-09-14T12:00:02Z",
+ });
+ const replayed = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: replayedPrompt,
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-14T12:01:00Z",
+ turnCompletedAt: "2026-09-14T12:01:03Z",
+ });
+
+ await expect
+ .poll(wsDrop.droppedCount, {
+ timeout: 10_000,
+ message: "expected the first durable message event to be dropped",
+ })
+ .toBeGreaterThan(0);
+ await expect
+ .poll(wsDrop.recoveryResponseCount, {
+ timeout: 15_000,
+ message: "expected core recovery snapshots after the ordered gap",
+ })
+ .toBeGreaterThan(0);
+
+ const chat = session.activeChat();
+ await expect(chat.locator(`#msg-${skipped.messageId}`)).toBeVisible({ timeout: 15_000 });
+ await expect(chat.locator(`#msg-${replayed.messageId}`)).toBeVisible({ timeout: 15_000 });
+ await expect(
+ chat.locator(`#msg-${skipped.messageId}`).getByTestId("message-turn-duration"),
+ ).toBeVisible();
+ await expect(
+ chat.locator(`#msg-${replayed.messageId}`).getByTestId("message-turn-duration"),
+ ).toBeVisible();
+
+ const laterLive = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: "core recovery later live update",
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-14T12:02:00Z",
+ turnCompletedAt: "2026-09-14T12:02:01Z",
+ });
+ await expect(chat.locator(`#msg-${laterLive.messageId}`)).toBeVisible({ timeout: 15_000 });
+ expect(gatewayCloseCount, "core recovery must keep the gateway socket connected").toBe(0);
+ });
+
+ test("rebinds an expired plugin continuation and keeps paging without remount", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ await installFixturePlugin(testPage);
+ const expiry = await routeMainWebSocketWithExpiredPluginSnapshot(testPage);
+ const renewalRequests: string[] = [];
+ testPage.on("request", (request) => {
+ if (request.url().includes("/conversation/continuation/renew")) {
+ renewalRequests.push(request.url());
+ }
+ });
+
+ const { taskId, sessionId } = await createSeededConversation(
+ apiClient,
+ seedData,
+ "Plugin continuation recovery",
+ );
+ for (const [index, content] of [
+ "plugin recovery oldest prompt",
+ "plugin recovery middle prompt",
+ "plugin recovery newest prompt",
+ ].entries()) {
+ await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content,
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: `2026-09-14T12:0${index}:00Z`,
+ turnCompletedAt: `2026-09-14T12:0${index}:01Z`,
+ });
+ }
+
+ await testPage.goto(`/t/${taskId}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ expiry.expireNextPluginSnapshot();
+ await session.addPanelButton().click();
+ const panelMenuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY);
+ await expect(panelMenuItem).toBeVisible();
+ await panelMenuItem.click();
+
+ const panel = testPage.getByTestId("fixture-prompt-history-panel");
+ await expect(panel).toBeVisible({ timeout: 15_000 });
+ await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(2);
+ await expect.poll(expiry.modifiedCount).toBe(1);
+
+ await panel.getByTestId("fixture-prompt-history-load-more").click();
+ await expect
+ .poll(expiry.pluginSubscribeCount, {
+ timeout: 15_000,
+ message: "expected a fresh plugin subscription after expiry",
+ })
+ .toBeGreaterThan(1);
+ await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(3);
+ expect(renewalRequests).toHaveLength(0);
+
+ const laterLive = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: "plugin recovery later live prompt",
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-14T12:03:00Z",
+ turnCompletedAt: "2026-09-14T12:03:01Z",
+ });
+ await expect(panel.locator(`[data-message-id="${laterLive.messageId}"]`)).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+});
diff --git a/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts
new file mode 100644
index 0000000000..1b0cd5d493
--- /dev/null
+++ b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts
@@ -0,0 +1,87 @@
+// Routing: /t/{taskId}. The mobile- prefix selects the mobile-chrome project.
+import { expect, test } from "../../fixtures/test-base";
+import { installFixturePlugin, uninstallFixturePlugin } from "../../helpers/plugin-fixture";
+import { SessionPage } from "../../pages/session-page";
+
+const PANEL_OPTION = "mobile-plugin-panel-option-kandev-plugin-e2e-prompt-history-plugin";
+
+test.describe("Mobile prompt-history fixture", () => {
+ test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient));
+
+ test("pages, previews aliases, and navigates through the Host facade", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ await installFixturePlugin(testPage);
+ await apiClient.createPrompt("daily-mobile", "Mobile saved prompt preview");
+
+ const task = await apiClient.createTask(seedData.workspaceId, "Mobile prompt history parity", {
+ description: "mobile fixture initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "IDLE",
+ agentProfileId: seedData.agentProfileId,
+ repositoryId: seedData.repositoryId,
+ });
+ for (let index = 0; index < 3; index += 1) {
+ await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: index === 2 ? "mobile latest @daily-mobile" : `mobile older ${index}`,
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: `2026-09-07T12:0${index}:00Z`,
+ turnCompletedAt: `2026-09-07T12:0${index}:01Z`,
+ });
+ }
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ const panelsButton = testPage.getByRole("button", { name: "Panels" });
+ await expect(panelsButton).toBeVisible({ timeout: 15_000 });
+ expect((await panelsButton.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await panelsButton.tap();
+ const option = testPage.getByTestId(PANEL_OPTION);
+ await expect(option).toBeVisible({ timeout: 10_000 });
+ expect((await option.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await option.tap();
+
+ const panel = testPage.getByTestId("fixture-prompt-history-panel");
+ if (!(await panel.isVisible())) {
+ await panelsButton.tap();
+ await option.tap();
+ }
+ await expect(panel).toBeVisible({ timeout: 10_000 });
+ await expect(panel).toHaveAttribute("data-presentation", "mobile");
+ expect(
+ await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
+
+ const loadOlder = panel.getByTestId("fixture-prompt-history-load-more");
+ await expect(loadOlder).toBeVisible();
+ expect((await loadOlder.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await loadOlder.tap();
+ await expect(panel.getByTestId("fixture-prompt-history-row")).toHaveCount(3);
+
+ await panel.getByTestId("fixture-prompt-history-row").first().getByRole("button").first().tap();
+ const alias = panel.getByTestId("custom-prompt-mention");
+ await expect(alias).toHaveAttribute("data-prompt-name", "daily-mobile");
+ expect((await alias.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await alias.tap();
+ await expect(testPage.getByText("Mobile saved prompt preview")).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await expect(testPage.getByText("Mobile saved prompt preview")).toBeHidden();
+
+ const openPrompt = panel.getByRole("button", { name: "Open prompt" });
+ await expect(openPrompt).toBeVisible();
+ expect((await openPrompt.boundingBox())?.height).toBeGreaterThanOrEqual(44);
+ await openPrompt.tap();
+ await expect(session.activeChat().getByText("mobile latest", { exact: false })).toBeVisible();
+ });
+});
diff --git a/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts b/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts
new file mode 100644
index 0000000000..afddd0bc21
--- /dev/null
+++ b/apps/web/e2e/tests/plugins/prompt-history-plugin.spec.ts
@@ -0,0 +1,136 @@
+import { expect, test } from "../../fixtures/test-base";
+import {
+ installFixturePlugin,
+ PLUGIN_ID,
+ uninstallFixturePlugin,
+} from "../../helpers/plugin-fixture";
+import { SessionPage } from "../../pages/session-page";
+
+const PANEL_KEY = "prompt-history-plugin";
+
+test.describe("Packaged prompt-history fixture", () => {
+ test.afterEach(async ({ apiClient }) => uninstallFixturePlugin(apiClient));
+
+ test("uses only Host conversation capabilities for paging, live transitions, and navigation", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ await installFixturePlugin(testPage);
+ await apiClient.createPrompt("daily", "Saved daily prompt preview");
+ const task = await apiClient.createTask(seedData.workspaceId, "Prompt history plugin parity", {
+ description: "fixture initial prompt",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+ const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
+ state: "IDLE",
+ agentProfileId: seedData.agentProfileId,
+ repositoryId: seedData.repositoryId,
+ });
+ await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: "fixture oldest prompt",
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-07T11:59:00Z",
+ turnCompletedAt: "2026-09-07T11:59:01Z",
+ });
+ const older = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: "fixture older prompt",
+ authorType: "user",
+ newTurn: true,
+ turnStartedAt: "2026-09-07T12:00:00Z",
+ turnCompletedAt: "2026-09-07T12:00:01Z",
+ });
+ const live = await apiClient.seedSessionMessage(sessionId, {
+ type: "message",
+ content: "fixture live prompt @daily",
+ authorType: "user",
+ metadata: { sender_task_id: "fixture-agent-task" },
+ newTurn: true,
+ turnStartedAt: "2026-09-07T12:01:00Z",
+ });
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.addPanelButton().click();
+ const menuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY);
+ await expect(menuItem).toHaveText(/Prompt history fixture/);
+ await menuItem.click();
+
+ const panel = testPage.getByTestId("fixture-prompt-history-panel");
+ await expect(panel).toBeVisible({ timeout: 10_000 });
+ await expect(panel.getByTestId("fixture-prompt-history-row").first()).toContainText("Prompt");
+ const loadOlder = panel.getByTestId("fixture-prompt-history-load-more");
+ await expect(loadOlder).toBeVisible();
+ await loadOlder.click();
+ await expect(panel.locator(`[data-message-id="${live.messageId}"]`)).toContainText(
+ "Sent by agent",
+ );
+ if (!live.turnId) throw new Error("live fixture message did not create a turn");
+ await apiClient.completeSessionTurn(live.turnId);
+ await expect(
+ panel
+ .locator(`[data-message-id="${live.messageId}"]`)
+ .getByTestId("fixture-prompt-history-duration"),
+ ).toBeVisible();
+ await expect(panel.getByText("Prompt 1", { exact: false }).first()).toBeVisible();
+ await apiClient.updateSessionMessage(live.messageId, "fixture updated prompt @daily");
+ await panel
+ .getByTestId("fixture-prompt-history-row")
+ .first()
+ .getByRole("button")
+ .first()
+ .click();
+ await expect(panel.getByTestId("fixture-prompt-history-detail")).toContainText(
+ "fixture updated prompt",
+ );
+ const alias = panel.getByTestId("custom-prompt-mention");
+ await expect(alias).toHaveAttribute("data-prompt-name", "daily");
+ await alias.click();
+ await expect(testPage.getByText("Saved daily prompt preview")).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await expect(testPage.getByText("Saved daily prompt preview")).toBeHidden();
+
+ await panel.getByRole("button", { name: "Open prompt" }).click();
+ await expect(session.activeChat().locator(`#msg-${live.messageId}`)).toBeAttached();
+ const chatMessage = session.activeChat().locator(`#msg-${live.messageId}`);
+ await chatMessage.hover();
+ await chatMessage.getByRole("button", { name: "Mark message as favorite" }).click();
+ await expect(panel.locator(`[data-message-id="${live.messageId}"]`)).toHaveAttribute(
+ "data-favorite",
+ "true",
+ );
+
+ await apiClient.deleteSessionMessage(older.messageId);
+ await expect(panel.locator(`[data-message-id="${older.messageId}"]`)).toHaveCount(0);
+
+ await testPage.evaluate(() => {
+ document.cookie = "kandev_locale=pseudo; path=/; max-age=31536000; SameSite=Lax";
+ });
+ await testPage.reload();
+ await session.waitForLoad();
+ await session.addPanelButton().click();
+ const pseudoMenuItem = session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY);
+ await expect(pseudoMenuItem).toHaveText(/Ƥřǿɱƥŧ ħīşŧǿřẏ ƒīẋŧŭřḗ/);
+ await pseudoMenuItem.click();
+ await expect(testPage.getByTestId("fixture-prompt-history-panel")).toContainText(/Ƥřǿɱƥŧ/);
+ await testPage.evaluate(() => {
+ document.cookie = "kandev_locale=en; path=/; max-age=31536000; SameSite=Lax";
+ });
+ await testPage.reload();
+ await session.waitForLoad();
+ await session.addPanelButton().click();
+ await session.addPanelPluginItem(PLUGIN_ID, PANEL_KEY).click();
+ const finalPanel = testPage.getByTestId("fixture-prompt-history-panel");
+ await expect(finalPanel).toBeVisible({ timeout: 10_000 });
+ await apiClient.deleteSession(sessionId);
+ await expect(finalPanel).toHaveAttribute("data-removed", "true");
+ await expect(finalPanel.locator(`[data-message-id="${live.messageId}"]`)).toBeVisible();
+ });
+});
diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.test.ts b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts
index 19138eff6c..c5834238b7 100644
--- a/apps/web/hooks/domains/session/message-window-reconciliation.test.ts
+++ b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts
@@ -68,6 +68,22 @@ describe("reconcileLatestMessageWindow", () => {
expect(result.oldestCursor).toBe("fetched-3");
});
+ it("removes stale rows from an authoritative window while retaining older pages", () => {
+ const older = message("older-1", 1);
+ const stale = message("stale-3", 3);
+ const fetched = [message("fresh-3", 3), message("fresh-4", 4)];
+
+ const result = reconcileLatestMessageWindow({
+ cachedAtRequest: [older, stale],
+ cachedAtResponse: [older, stale],
+ fetched,
+ authoritative: true,
+ });
+
+ expect(result.messages.map(({ id }) => id)).toEqual(["older-1", "fresh-3", "fresh-4"]);
+ expect(result.oldestCursor).toBe("older-1");
+ });
+
it("sorts timestamps using sub-millisecond precision before the id tie-breaker", () => {
const earlier = message("z-earlier", 0, ".000001");
const later = message("a-later", 0, ".000002");
diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.ts b/apps/web/hooks/domains/session/message-window-reconciliation.ts
index eb6b7b0673..71dc3c7f9e 100644
--- a/apps/web/hooks/domains/session/message-window-reconciliation.ts
+++ b/apps/web/hooks/domains/session/message-window-reconciliation.ts
@@ -42,6 +42,10 @@ function joinMessages(base: Message[], extras: Message[]): Message[] {
return [...base, ...extras.filter((message) => !baseIds.has(message.id))].sort(compareMessages);
}
+function isPendingLocalMessage(message: Message): boolean {
+ return message.metadata?.client_queue_id !== undefined;
+}
+
/**
* Reconcile a bounded newest-page response with the session cache while
* preserving one contiguous pagination interval.
@@ -50,9 +54,17 @@ export function reconcileLatestMessageWindow(params: {
cachedAtRequest: Message[];
cachedAtResponse: Message[];
fetched: Message[];
+ authoritative?: boolean;
}): LatestMessageWindow {
- const { cachedAtRequest, cachedAtResponse, fetched } = params;
+ const { cachedAtRequest, cachedAtResponse, fetched, authoritative = false } = params;
if (fetched.length === 0) {
+ if (authoritative) {
+ const pendingLocal = cachedAtResponse.filter(isPendingLocalMessage);
+ return {
+ messages: pendingLocal,
+ oldestCursor: pendingLocal[0]?.id ?? null,
+ };
+ }
return {
messages: cachedAtResponse,
oldestCursor: cachedAtResponse[0]?.id ?? null,
@@ -64,6 +76,18 @@ export function reconcileLatestMessageWindow(params: {
const fetchedIds = new Set(orderedFetched.map((message) => message.id));
const overlapsCachedWindow = cachedAtRequest.some((message) => fetchedIds.has(message.id));
+ if (authoritative) {
+ const cachedAtRequestIds = new Set(cachedAtRequest.map((message) => message.id));
+ const retained = cachedAtResponse.filter((message) => {
+ if (fetchedIds.has(message.id)) return false;
+ if (isPendingLocalMessage(message)) return true;
+ if (compareMessages(message, fetchedBoundary) < 0) return true;
+ return !cachedAtRequestIds.has(message.id);
+ });
+ const messages = joinMessages(orderedFetched, retained);
+ return { messages, oldestCursor: messages[0]?.id ?? null };
+ }
+
if (overlapsCachedWindow) {
const messages = joinMessages(orderedFetched, cachedAtResponse);
return { messages, oldestCursor: messages[0]?.id ?? null };
diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.ts b/apps/web/hooks/domains/session/use-session-message-fetch.ts
index a1849deef4..a9798a84fb 100644
--- a/apps/web/hooks/domains/session/use-session-message-fetch.ts
+++ b/apps/web/hooks/domains/session/use-session-message-fetch.ts
@@ -44,13 +44,22 @@ export function useInitialMessageLoadingState(
]);
}
-export function getHydratedMessagesForGeneration(
- hydrationRef: SessionHydrationRef | undefined,
- sessionId: string,
- readiness: Promise,
- hydrationKey: string | undefined,
- store: SessionMessageStore,
-): Message[] | undefined {
+export function getHydratedMessagesForGeneration({
+ hydrationRef,
+ sessionId,
+ readiness,
+ hydrationKey,
+ store,
+ force = false,
+}: {
+ hydrationRef: SessionHydrationRef | undefined;
+ sessionId: string;
+ readiness: Promise;
+ hydrationKey: string | undefined;
+ store: SessionMessageStore;
+ force?: boolean;
+}): Message[] | undefined {
+ if (force) return undefined;
const generation = hydrationRef?.current;
if (
!hydrationKey ||
@@ -116,6 +125,7 @@ type DoFetchMessagesParams = {
hydrationRef?: SessionHydrationRef,
hydrationKey?: string,
onRetry?: () => void,
+ options?: MessageFetchOptions,
) => Promise;
onError?: (error: unknown) => void;
isActive?: () => boolean;
@@ -123,6 +133,12 @@ type DoFetchMessagesParams = {
canFinalizeLoading?: () => boolean;
hydrationRef?: SessionHydrationRef;
hydrationKey?: string;
+ options?: MessageFetchOptions;
+};
+
+export type MessageFetchOptions = {
+ force?: boolean;
+ authoritative?: boolean;
};
export async function doFetchMessages({
@@ -140,8 +156,9 @@ export async function doFetchMessages({
canFinalizeLoading,
hydrationRef,
hydrationKey,
-}: DoFetchMessagesParams): Promise {
- if (isInactive(isActive)) return;
+ options,
+}: DoFetchMessagesParams): Promise {
+ if (isInactive(isActive)) return false;
beginSessionFetch(taskSessionId);
setIsLoading(true);
const isInitialHistoryFetch = lastFetchedSessionIdRef.current !== taskSessionId;
@@ -155,20 +172,28 @@ export async function doFetchMessages({
setIsWaitingForInitialMessages(true);
}
try {
- await fetchAndStoreMessages(taskSessionId, store, isActive, hydrationRef, hydrationKey, () =>
- setHistoryStatus("retrying"),
+ await fetchAndStoreMessages(
+ taskSessionId,
+ store,
+ isActive,
+ hydrationRef,
+ hydrationKey,
+ () => setHistoryStatus("retrying"),
+ options,
);
- if (isInactive(isActive)) return;
+ if (isInactive(isActive)) return false;
lastFetchedSessionIdRef.current = taskSessionId;
setHistoryStatus("ready");
setHistoryError(null);
setIsWaitingForInitialMessages(false);
+ return true;
} catch (error) {
- if (isInactive(isActive)) return;
+ if (isInactive(isActive)) return false;
if (onError) onError(error);
else console.error("Failed to fetch messages:", error);
setHistoryStatus("unavailable");
setHistoryError(error);
+ return false;
} finally {
const active = !isInactive(isActive);
if (endSessionFetch(taskSessionId)) {
diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts
index 1a718ddc4f..51b8fb9227 100644
--- a/apps/web/hooks/domains/session/use-session-messages.test.ts
+++ b/apps/web/hooks/domains/session/use-session-messages.test.ts
@@ -10,6 +10,8 @@ const mockWebSocketClient = {
request: vi.fn(),
subscribeSession: vi.fn(),
subscribeSessionWithReady: vi.fn(),
+ registerCoreSessionRecovery: vi.fn(() => vi.fn()),
+ retryCoreSessionRecovery: vi.fn(() => undefined),
};
const mockState = {
diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts
index 6b22fb4f8b..841a692642 100644
--- a/apps/web/hooks/domains/session/use-session-messages.ts
+++ b/apps/web/hooks/domains/session/use-session-messages.ts
@@ -20,6 +20,7 @@ import {
doFetchMessages,
getHydratedMessagesForGeneration,
recordHydratedGeneration,
+ type MessageFetchOptions,
type SessionHydrationRef,
} from "./use-session-message-fetch";
import { useMessageFetchState } from "./use-message-fetch-state";
@@ -35,6 +36,11 @@ const INITIAL_FETCH_LIMIT = 100;
const RUNNING_BACKFILL_INITIAL_DELAY_MS = 1200;
const RUNNING_BACKFILL_INTERVAL_MS = 5000;
const MAX_HISTORY_FETCH_ATTEMPTS = 2;
+const TERMINAL_SESSION_STATES: Partial> = {
+ WAITING_FOR_INPUT: true,
+ COMPLETED: true,
+ FAILED: true,
+};
// Monotonic guard against stale concurrent fetches. Each fetch claims an
// increasing sequence number before awaiting; a completion only merges if no
@@ -204,9 +210,10 @@ function requestSessionMessages(
sessionId: string,
readiness: Promise,
cachedAtRequest: Message[],
+ force = false,
): InFlightMessageRequest {
const existing = inFlightMessageRequests.get(sessionId);
- if (existing?.readiness === readiness && !existing.settled) return existing;
+ if (!force && existing?.readiness === readiness && !existing.settled) return existing;
const requestParams = {
session_id: sessionId,
@@ -242,13 +249,22 @@ function requestSessionMessages(
}
/** Fetch latest messages via WS and merge with any that arrived via live notifications. */
-async function fetchAndStoreMessagesAttempt(
- sessionId: string,
- store: ReturnType,
- isActive?: () => boolean,
- hydrationRef?: SessionHydrationRef,
- hydrationKey?: string,
-): Promise {
+// eslint-disable-next-line complexity -- The ordered snapshot path keeps its guards adjacent to the commit fence.
+async function fetchAndStoreMessagesAttempt({
+ sessionId,
+ store,
+ isActive,
+ hydrationRef,
+ hydrationKey,
+ options,
+}: {
+ sessionId: string;
+ store: ReturnType;
+ isActive?: () => boolean;
+ hydrationRef?: SessionHydrationRef;
+ hydrationKey?: string;
+ options?: MessageFetchOptions;
+}): Promise {
const client = getWebSocketClient();
if (!client) {
return [];
@@ -260,25 +276,32 @@ async function fetchAndStoreMessagesAttempt(
const readiness = client.getSessionSubscriptionReadiness(sessionId);
await readiness;
if (isActive && !isActive()) return [];
- const hydratedMessages = getHydratedMessagesForGeneration(
+ const hydratedMessages = getHydratedMessagesForGeneration({
hydrationRef,
sessionId,
readiness,
hydrationKey,
store,
- );
+ force: options?.force,
+ });
if (hydratedMessages !== undefined) return hydratedMessages;
// The messages fetch is the session-entry chokepoint: any path that opens a
// session's transcript must also make its turns resolvable. Start it only
// after subscription acknowledgement so the REST snapshot cannot race the
// initial WebSocket subscription registration.
- void ensureSessionTurnsLoaded(sessionId, store, { readiness });
+ if (!options?.force) void ensureSessionTurnsLoaded(sessionId, store, { readiness });
const cachedAtRequest = store.getState().messages.bySession[sessionId] ?? [];
const seq = nextFetchSeq();
// Keep the snapshot on the deduplicated request. Concurrent callers may
// observe different cache contents, but reconciliation must use the
// baseline captured by the caller that actually issued the network request.
- const request = requestSessionMessages(client, sessionId, readiness, cachedAtRequest);
+ const request = requestSessionMessages(
+ client,
+ sessionId,
+ readiness,
+ cachedAtRequest,
+ options?.force,
+ );
const response = await request.promise;
if (isActive && !isActive()) return [];
const fetched = [...(response.messages ?? [])].reverse();
@@ -287,6 +310,7 @@ async function fetchAndStoreMessagesAttempt(
cachedAtRequest: request.cachedAtRequest,
cachedAtResponse: store.getState().messages.bySession[sessionId] ?? [],
fetched,
+ authoritative: options?.authoritative,
});
// Stale-fetch guard: if a newer fetch for this session already merged while
// this one was in flight, skip the merge so the older snapshot can't drop
@@ -331,16 +355,18 @@ async function fetchAndStoreMessages(
hydrationRef?: SessionHydrationRef,
hydrationKey?: string,
onRetry?: () => void,
+ options?: MessageFetchOptions,
): Promise {
for (let attempt = 0; attempt < MAX_HISTORY_FETCH_ATTEMPTS; attempt += 1) {
try {
- return await fetchAndStoreMessagesAttempt(
+ return await fetchAndStoreMessagesAttempt({
sessionId,
store,
isActive,
hydrationRef,
hydrationKey,
- );
+ options,
+ });
} catch (error) {
const retryable =
isWebSocketRequestTimeoutError(error) &&
@@ -352,6 +378,7 @@ async function fetchAndStoreMessages(
}
return [];
}
+
/**
* When the initial fetch window contains no user/agent message rows (common
* when the latest turn produced hundreds of tool calls), the chat would render
@@ -387,9 +414,8 @@ function useTerminalStateFetch(
active = false;
};
if (!taskSessionId || connectionStatus !== "connected") return deactivate;
- if (!taskSessionState || hasAgentMessage) return deactivate;
- const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]);
- if (!terminalStates.has(taskSessionState)) return deactivate;
+ if (!taskSessionState) return deactivate;
+ if (!TERMINAL_SESSION_STATES[taskSessionState]) return deactivate;
const key = `${taskSessionId}:${taskSessionState}`;
if (lastFetchStateKeyRef.current === key) return deactivate;
lastFetchStateKeyRef.current = key;
@@ -896,6 +922,13 @@ function useSessionHistoryRecoveryState({
return;
}
const generation = sessionFetchGenerationRef.current;
+ const clientRecovery = getWebSocketClient()?.retryCoreSessionRecovery(retrySessionId);
+ if (clientRecovery) {
+ hydrationRef.current = null;
+ fetchState.lastFetchedSessionIdRef.current = null;
+ void clientRecovery;
+ return;
+ }
fetchState.lastFetchedSessionIdRef.current = null;
fetchState.cachedRefreshGenerationRef.current += 1;
hydrationRef.current = null;
@@ -928,6 +961,74 @@ function useSessionHistoryRecoveryState({
return { retryHistory, sessionFetchGenerationRef };
}
+function useCoreSessionRecovery({
+ taskSessionId,
+ connectionStatus,
+ store,
+ fetchRefs,
+ hydrationRef,
+ hydrationKey,
+ sessionFetchGenerationRef,
+}: {
+ taskSessionId: string | null;
+ connectionStatus: string;
+ store: ReturnType;
+ fetchRefs: ReturnType["refs"];
+ hydrationRef: SessionHydrationRef;
+ hydrationKey: string;
+ sessionFetchGenerationRef: MutableRefObject;
+}): void {
+ useEffect(() => {
+ if (!taskSessionId || connectionStatus !== "connected") return;
+ const client = getWebSocketClient();
+ if (!client) return;
+ const generation = sessionFetchGenerationRef.current;
+ let active = true;
+ const isActive = () =>
+ active &&
+ sessionFetchGenerationRef.current === generation &&
+ store.getState().taskSessions.items[taskSessionId] !== undefined;
+
+ const unregister = client.registerCoreSessionRecovery(taskSessionId, async () => {
+ if (!isActive()) return false;
+ fetchRefs.lastFetchedSessionIdRef.current = null;
+ hydrationRef.current = null;
+ const readiness = client.getSessionSubscriptionReadiness(taskSessionId);
+ const [messagesReady, turnsReady] = await Promise.all([
+ doFetchMessages({
+ taskSessionId,
+ ...fetchRefs,
+ fetchAndStoreMessages,
+ isActive,
+ canFinalizeLoading: () => isActive(),
+ hydrationRef,
+ hydrationKey,
+ options: { force: true, authoritative: true },
+ }),
+ ensureSessionTurnsLoaded(taskSessionId, store, {
+ readiness,
+ force: true,
+ replace: true,
+ }),
+ ]);
+ return isActive() && messagesReady && turnsReady;
+ });
+
+ return () => {
+ active = false;
+ unregister();
+ };
+ }, [
+ connectionStatus,
+ fetchRefs,
+ hydrationKey,
+ hydrationRef,
+ sessionFetchGenerationRef,
+ store,
+ taskSessionId,
+ ]);
+}
+
export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn {
const store = useAppStoreApi();
const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } =
@@ -962,6 +1063,15 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
hydrationRef,
hydrationKey,
});
+ useCoreSessionRecovery({
+ taskSessionId,
+ connectionStatus,
+ store,
+ fetchRefs,
+ hydrationRef,
+ hydrationKey,
+ sessionFetchGenerationRef,
+ });
useSessionLifecycleSubscriptions({
taskSessionId,
taskSessionState,
diff --git a/apps/web/hooks/domains/session/use-session-recovery.test.tsx b/apps/web/hooks/domains/session/use-session-recovery.test.tsx
new file mode 100644
index 0000000000..b861bb7f2e
--- /dev/null
+++ b/apps/web/hooks/domains/session/use-session-recovery.test.tsx
@@ -0,0 +1,165 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Message, Turn } from "@/lib/types/http";
+import { sessionId, taskId } from "@/lib/types/ids";
+
+const listSessionTurns = vi.fn();
+const recoveryHandlers = new Map Promise>();
+const webSocketClient = {
+ getSessionSubscriptionReadiness: vi.fn(() => Promise.resolve()),
+ request: vi.fn(),
+ subscribeSessionWithReady: vi.fn(() => ({
+ ready: Promise.resolve(),
+ unsubscribe: vi.fn(),
+ })),
+ registerCoreSessionRecovery: vi.fn((id: string, handler: () => Promise) => {
+ recoveryHandlers.set(id, handler);
+ return () => recoveryHandlers.delete(id);
+ }),
+ retryCoreSessionRecovery: vi.fn(() => undefined),
+};
+
+const staleMessage = {
+ id: "message-stale",
+ task_id: taskId("task-1"),
+ session_id: sessionId("sess-1"),
+ author_type: "agent",
+ content: "old content",
+ type: "message",
+ created_at: "2026-09-14T12:00:00Z",
+} as Message;
+const repairedMessage = { ...staleMessage, content: "repaired content" };
+const repairedTurn = {
+ id: "turn-1",
+ task_id: taskId("task-1"),
+ session_id: sessionId("sess-1"),
+ started_at: "2026-09-14T11:59:00Z",
+ completed_at: "2026-09-14T12:01:00Z",
+ created_at: "2026-09-14T11:59:00Z",
+ updated_at: "2026-09-14T12:01:00Z",
+ metadata: { model: "repaired" },
+} as Turn;
+
+const state = {
+ messages: {
+ bySession: { "sess-1": [staleMessage] as Message[] },
+ metaBySession: {
+ "sess-1": {
+ historyInitialized: true,
+ hasMore: false,
+ oldestCursor: staleMessage.id,
+ isLoading: false,
+ isLoadingMore: false,
+ },
+ },
+ },
+ taskSessions: { items: { "sess-1": { id: "sess-1", state: "IDLE" } } },
+ turns: {
+ bySession: { "sess-1": [] as Turn[] },
+ activeBySession: { "sess-1": null as string | null },
+ loadedBySession: { "sess-1": true },
+ reconcileEpochBySession: { "sess-1": 0 },
+ settledBoundaryBySession: {},
+ },
+ connection: { status: "connected" },
+ mergeMessages: vi.fn(),
+ setMessagesLoading: vi.fn(),
+ mergeTurnsSnapshot: vi.fn(),
+ addTurn: vi.fn(),
+ markTurnsLoaded: vi.fn(),
+ reconcileActiveTurnAfterHydration: vi.fn(),
+ setActiveTurn: vi.fn(),
+};
+const store = { getState: () => state };
+
+vi.mock("@/lib/api/domains/session-api", () => ({
+ listSessionTurns: (...args: unknown[]) => listSessionTurns(...args),
+}));
+
+vi.mock("@/lib/ws/connection", () => ({
+ getWebSocketClient: () => webSocketClient,
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (value: typeof state) => unknown) => selector(state),
+ useAppStoreApi: () => store,
+}));
+
+import { useSessionMessages } from "./use-session-messages";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ recoveryHandlers.clear();
+ state.messages.bySession["sess-1"] = [staleMessage];
+ state.messages.metaBySession["sess-1"] = {
+ historyInitialized: true,
+ hasMore: false,
+ oldestCursor: staleMessage.id,
+ isLoading: false,
+ isLoadingMore: false,
+ };
+ state.turns.bySession["sess-1"] = [];
+ state.turns.loadedBySession["sess-1"] = true;
+ state.turns.activeBySession["sess-1"] = null;
+ state.connection.status = "connected";
+ listSessionTurns.mockImplementation(() =>
+ Promise.resolve({ turns: repairedTurn.id === "turn-1" ? [repairedTurn] : [] }),
+ );
+ let messageRequest = 0;
+ webSocketClient.request.mockImplementation(() => {
+ messageRequest += 1;
+ return Promise.resolve({
+ messages: messageRequest === 1 ? [staleMessage] : [repairedMessage],
+ has_more: false,
+ });
+ });
+ state.mergeMessages.mockImplementation(
+ (sessionId: string, messages: Message[], meta?: Record) => {
+ const messagesBySession = state.messages.bySession as Record;
+ const metaBySession = state.messages.metaBySession as Record>;
+ messagesBySession[sessionId] = messages;
+ Object.assign(metaBySession[sessionId], meta);
+ },
+ );
+ state.mergeTurnsSnapshot.mockImplementation(
+ (sessionId: string, turns: Turn[], _epoch: number, options?: { replace?: boolean }) => {
+ const turnsBySession = state.turns.bySession as Record;
+ turnsBySession[sessionId] = options?.replace
+ ? turns
+ : [...turnsBySession[sessionId], ...turns];
+ (state.turns.loadedBySession as Record)[sessionId] = true;
+ },
+ );
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("core session recovery", () => {
+ it("rehydrates both core snapshots before resuming after invalid resume", async () => {
+ const { unmount } = renderHook(() => useSessionMessages("sess-1"));
+
+ await waitFor(() => expect(recoveryHandlers.has("sess-1")).toBe(true));
+ const recover = recoveryHandlers.get("sess-1");
+ if (!recover) throw new Error("Recovery owner was not registered");
+
+ await act(async () => {
+ await expect(recover()).resolves.toBe(true);
+ });
+
+ expect(state.messages.bySession["sess-1"]).toEqual([repairedMessage]);
+ expect(state.turns.bySession["sess-1"]).toEqual([repairedTurn]);
+ expect(state.mergeMessages).toHaveBeenCalledWith(
+ "sess-1",
+ [repairedMessage],
+ expect.objectContaining({ historyInitialized: true }),
+ );
+ expect(state.mergeTurnsSnapshot).toHaveBeenCalledWith("sess-1", [repairedTurn], 0, {
+ replace: true,
+ });
+
+ unmount();
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts b/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts
index 20f09e969d..f2025d16f7 100644
--- a/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts
+++ b/apps/web/hooks/domains/session/use-session-turns-hydration.test.ts
@@ -211,7 +211,7 @@ describe("ensureSessionTurnsLoaded — hydration and marker", () => {
sessionId: string,
store: unknown,
options: { readiness: Promise },
- ) => Promise
+ ) => Promise
)(SESSION_ID, store, { readiness });
expect(mockListSessionTurns).toHaveBeenCalledTimes(1);
@@ -219,6 +219,30 @@ describe("ensureSessionTurnsLoaded — hydration and marker", () => {
expect.objectContaining({ id: "turn-after-reconnect" }),
);
});
+
+ it("forces an authoritative replacement even when the current generation is loaded", async () => {
+ const store = makeStore();
+ store.getState().turns.loadedBySession[SESSION_ID] = true;
+ mockListSessionTurns.mockResolvedValue({
+ turns: [makeTurn("turn-repaired")],
+ total: 1,
+ });
+
+ await expect(
+ ensureSessionTurnsLoaded(SESSION_ID, store as never, {
+ readiness: Promise.resolve(),
+ force: true,
+ replace: true,
+ }),
+ ).resolves.toBe(true);
+
+ expect(store.mergeTurnsSnapshot).toHaveBeenCalledWith(
+ SESSION_ID,
+ [expect.objectContaining({ id: "turn-repaired" })],
+ 0,
+ { replace: true },
+ );
+ });
});
describe("ensureSessionTurnsLoaded — guards and races", () => {
@@ -311,7 +335,7 @@ describe("ensureSessionTurnsLoaded — guards and races", () => {
mockListSessionTurns.mockRejectedValue(new Error(BACKEND_DOWN));
const store = makeStore();
- await ensureSessionTurnsLoaded(SESSION_ID, store as never);
+ await expect(ensureSessionTurnsLoaded(SESSION_ID, store as never)).resolves.toBe(false);
// Bounded: no unbounded hammering of a failing endpoint.
expect(mockListSessionTurns).toHaveBeenCalledTimes(3);
diff --git a/apps/web/hooks/domains/session/use-session-turns-hydration.ts b/apps/web/hooks/domains/session/use-session-turns-hydration.ts
index 52ec062c1a..82ef98eef8 100644
--- a/apps/web/hooks/domains/session/use-session-turns-hydration.ts
+++ b/apps/web/hooks/domains/session/use-session-turns-hydration.ts
@@ -8,7 +8,7 @@ const debug = createDebugLogger("messages:fetch:turns");
// loaded marker lives in the store (`turns.loadedBySession`) because it must
// be seeded by SSR hydration too; the module maps deduplicate in-flight work
// and associate a successful load with the current subscription readiness.
-const inFlightTurnsLoad = new Map>();
+const inFlightTurnsLoad = new Map>();
const lastHydrationReadiness = new Map | undefined>();
// Transient failures (network blip, backend restart) must not leave a
@@ -81,17 +81,18 @@ async function fetchAndReconcileSessionTurns(
store: ReturnType,
sessionId: string,
hydrationEpoch: number,
+ replace: boolean,
): Promise {
try {
const { turns } = await listSessionTurns(sessionId, { cache: "no-store" });
// Re-check before merging: the session may have been removed while the
// request was in flight.
const state = store.getState();
- if (!state.taskSessions.items[sessionId]) return true;
+ if (!state.taskSessions.items[sessionId]) return false;
// Merge rows, reconcile the active marker, and record the loaded marker in
// one store transaction. This keeps a live WS update from landing between
// separate row/marker writes and avoids one Zustand notification per row.
- store.getState().mergeTurnsSnapshot(sessionId, turns, hydrationEpoch);
+ store.getState().mergeTurnsSnapshot(sessionId, turns, hydrationEpoch, { replace });
return true;
} catch (err) {
debug("turn fetch failed", { sessionId, err });
@@ -124,14 +125,16 @@ async function fetchAndReconcileSessionTurns(
export async function ensureSessionTurnsLoaded(
sessionId: string,
store: ReturnType,
- options?: { readiness?: Promise },
-): Promise {
+ options?: { readiness?: Promise; force?: boolean; replace?: boolean },
+): Promise {
const readiness = options?.readiness;
+ const force = options?.force ?? false;
+ const replace = options?.replace ?? false;
while (true) {
const state = store.getState();
if (!state.taskSessions.items[sessionId]) {
lastHydrationReadiness.delete(sessionId);
- return;
+ return false;
}
const loaded = state.turns.loadedBySession[sessionId];
// A caller without a subscription-generation token keeps the historical
@@ -139,9 +142,10 @@ export async function ensureSessionTurnsLoaded(
// WebSocket subscription, so a reconnect gets one fresh REST snapshot.
if (
loaded &&
+ !force &&
(readiness === undefined || lastHydrationReadiness.get(sessionId) === readiness)
) {
- return;
+ return true;
}
const inFlight = inFlightTurnsLoad.get(sessionId);
@@ -160,16 +164,16 @@ export async function ensureSessionTurnsLoaded(
const promise = (async () => {
try {
for (let attempt = 1; ; attempt++) {
- if (await fetchAndReconcileSessionTurns(store, sessionId, hydrationEpoch)) {
+ if (await fetchAndReconcileSessionTurns(store, sessionId, hydrationEpoch, replace)) {
if (store.getState().taskSessions.items[sessionId]) {
lastHydrationReadiness.set(sessionId, readiness);
}
- return;
+ return true;
}
debug("turn fetch attempt failed", { sessionId, attempt });
if (attempt >= TURN_HYDRATION_MAX_ATTEMPTS) {
scheduleTurnHydrationRecovery(sessionId, store);
- return;
+ return false;
}
const delay = Math.min(
TURN_HYDRATION_RETRY_BASE_MS * 2 ** (attempt - 1),
@@ -182,7 +186,6 @@ export async function ensureSessionTurnsLoaded(
}
})();
inFlightTurnsLoad.set(sessionId, promise);
- await promise;
- return;
+ return await promise;
}
}
diff --git a/apps/web/hooks/processed-message-filtering.ts b/apps/web/hooks/processed-message-filtering.ts
index fd07501a09..65c0e4af7b 100644
--- a/apps/web/hooks/processed-message-filtering.ts
+++ b/apps/web/hooks/processed-message-filtering.ts
@@ -317,7 +317,9 @@ function findActiveClarification(
messages: Message[],
scope?: PendingClarificationScope,
): Message | undefined {
- return findPendingClarification(messages, scope) ?? undefined;
+ const pending = findPendingClarification(messages, scope);
+ const metadata = pending?.metadata as ClarificationRequestMetadata | undefined;
+ return metadata?.agent_disconnected === true ? undefined : (pending ?? undefined);
}
function isClarificationVisible(
diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts
index fe8d08cdc4..aa3b8d80d5 100644
--- a/apps/web/hooks/use-processed-messages.ts
+++ b/apps/web/hooks/use-processed-messages.ts
@@ -151,6 +151,7 @@ export type ProcessedMessagesOptions = {
hasOlderMessages?: boolean;
lastAgentError?: LastAgentError | null;
currentTurnId?: string | null;
+ currentTurnCompleted?: boolean;
pendingAction?: TaskPendingAction | null;
};
@@ -475,9 +476,13 @@ function usePendingClarificationState(messages: Message[], options: ProcessedMes
const scope = useMemo(
() =>
hasScopeKeys
- ? { currentTurnId: options.currentTurnId, pendingAction: options.pendingAction }
+ ? {
+ currentTurnId: options.currentTurnId,
+ currentTurnCompleted: options.currentTurnCompleted,
+ pendingAction: options.pendingAction,
+ }
: undefined,
- [hasScopeKeys, options.currentTurnId, options.pendingAction],
+ [hasScopeKeys, options.currentTurnCompleted, options.currentTurnId, options.pendingAction],
);
return {
scope,
diff --git a/apps/web/lib/plugins/conversation-event-projection.ts b/apps/web/lib/plugins/conversation-event-projection.ts
new file mode 100644
index 0000000000..2caa87e6e1
--- /dev/null
+++ b/apps/web/lib/plugins/conversation-event-projection.ts
@@ -0,0 +1,104 @@
+import type { RawSessionEvent } from "@/lib/ws/client";
+import type { PluginConversationMessage, PluginConversationTurn } from "./types";
+
+export function eventField(event: RawSessionEvent, key: string): unknown {
+ if (!event.payload || typeof event.payload !== "object" || !(key in event.payload)) {
+ return undefined;
+ }
+ return Reflect.get(event.payload, key);
+}
+
+export function eventString(event: RawSessionEvent, key: string): string | null {
+ const value = eventField(event, key);
+ return typeof value === "string" && value !== "" ? value : null;
+}
+
+function compareConversationTimestamps(left: string, right: string): number {
+ const leftMilliseconds = Date.parse(left);
+ const rightMilliseconds = Date.parse(right);
+ if (Number.isFinite(leftMilliseconds) && Number.isFinite(rightMilliseconds)) {
+ if (leftMilliseconds !== rightMilliseconds)
+ return leftMilliseconds < rightMilliseconds ? -1 : 1;
+ const leftFraction = left.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
+ const rightFraction = right.match(/\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
+ const leftNanoseconds = leftFraction.padEnd(9, "0").slice(0, 9);
+ const rightNanoseconds = rightFraction.padEnd(9, "0").slice(0, 9);
+ if (leftNanoseconds !== rightNanoseconds) {
+ return leftNanoseconds < rightNanoseconds ? -1 : 1;
+ }
+ return 0;
+ }
+ if (left < right) return -1;
+ if (left > right) return 1;
+ return 0;
+}
+
+function compareConversationIds(left: string, right: string): number {
+ if (left < right) return -1;
+ if (left > right) return 1;
+ return 0;
+}
+
+export function compareConversationMessages(
+ left: Pick,
+ right: Pick,
+ sort: "asc" | "desc",
+): number {
+ const created = compareConversationTimestamps(left.createdAt, right.createdAt);
+ const ordered = created === 0 ? compareConversationIds(left.id, right.id) : created;
+ return sort === "asc" ? ordered : -ordered;
+}
+
+export function eventMatchesTask(event: RawSessionEvent, taskId: string | null): boolean {
+ return taskId === null || event.task_id === taskId;
+}
+
+export function messageFromEvent(event: RawSessionEvent): PluginConversationMessage | null {
+ const id = eventString(event, "message_id");
+ const authorType = eventString(event, "author_type");
+ const contentValue = eventField(event, "content");
+ const content = typeof contentValue === "string" ? contentValue : null;
+ const createdAt = eventString(event, "created_at");
+ const explicitUpdatedAt = eventString(event, "updated_at");
+ const updatedAt = explicitUpdatedAt ?? (event.event_type === "message.added" ? createdAt : null);
+ if (
+ !id ||
+ (authorType !== "user" && authorType !== "agent") ||
+ content === null ||
+ !createdAt ||
+ !updatedAt
+ ) {
+ return null;
+ }
+ const promptIndex = eventField(event, "prompt_index");
+ return {
+ id,
+ taskId: event.task_id,
+ sessionId: event.session_id,
+ turnId: eventString(event, "turn_id") ?? undefined,
+ authorType,
+ type: eventString(event, "message_type") ?? "message",
+ content,
+ createdAt,
+ updatedAt,
+ promptIndex: typeof promptIndex === "number" ? promptIndex : undefined,
+ senderTaskId: eventString(event, "sender_task_id") ?? undefined,
+ };
+}
+
+export function turnFromEvent(event: RawSessionEvent): PluginConversationTurn | null {
+ const id = eventString(event, "turn_id") ?? eventString(event, "id");
+ const startedAt = eventString(event, "started_at");
+ const explicitUpdatedAt = eventString(event, "updated_at");
+ const updatedAt =
+ explicitUpdatedAt ?? (event.event_type === "session.turn.started" ? startedAt : null);
+ if (!id || !startedAt || !updatedAt) return null;
+ return {
+ id,
+ taskId: event.task_id,
+ sessionId: event.session_id,
+ startedAt,
+ completedAt: eventString(event, "completed_at") ?? undefined,
+ updatedAt,
+ };
+}
diff --git a/apps/web/lib/plugins/conversation-host-isolation.test.tsx b/apps/web/lib/plugins/conversation-host-isolation.test.tsx
new file mode 100644
index 0000000000..47fe0df77c
--- /dev/null
+++ b/apps/web/lib/plugins/conversation-host-isolation.test.tsx
@@ -0,0 +1,592 @@
+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 } from "./types";
+import { PluginConversationScopeProvider, pluginConversationApi } from "./conversation-host";
+
+const transport = vi.hoisted(() => ({
+ request: vi.fn(),
+ listener: null as ((event: RawSessionEvent) => 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) {
+ return () => undefined;
+ },
+ }),
+}));
+const BINDING_PATH_SUFFIX = "/conversation/binding";
+const FAR_FUTURE = "2099-01-01T00:00:00Z";
+const SNAPSHOT_CREATED_AT = "2026-09-07T12:00:00Z";
+
+function ScopedHarness({ sessionId, testId }: { sessionId: string; testId: string }) {
+ const state = pluginConversationApi.useSessionMessages({ sessionId });
+ return {String(state.hydrated)};
+}
+
+let userQueryState: PluginSessionMessagesState | null = null;
+let agentQueryState: PluginSessionMessagesState | null = null;
+let ascendingState: PluginSessionMessagesState | null = null;
+
+function AscendingHarness() {
+ ascendingState = pluginConversationApi.useSessionMessages({
+ sessionId: "session-1",
+ authorTypes: ["agent"],
+ sort: "asc",
+ pageSize: 1,
+ });
+ return (
+
+ {ascendingState.messages.map((item) => item.content).join("|")}
+
+ );
+}
+
+function TwoQueryHarness() {
+ userQueryState = pluginConversationApi.useSessionMessages({
+ sessionId: "session-1",
+ authorTypes: ["user"],
+ sort: "desc",
+ pageSize: 1,
+ });
+ agentQueryState = pluginConversationApi.useSessionMessages({
+ sessionId: "session-1",
+ authorTypes: ["agent"],
+ sort: "asc",
+ pageSize: 2,
+ });
+ return (
+ <>
+
+ {userQueryState.messages.map((message) => message.content).join("|")}
+
+
+ {agentQueryState.messages.map((message) => message.content).join("|")}
+
+ >
+ );
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+function message(id: string, content: string, createdAt: string, authorType: "user" | "agent") {
+ return {
+ id,
+ taskId: "task-1",
+ sessionId: "session-1",
+ authorType,
+ type: "message",
+ content,
+ createdAt,
+ updatedAt: createdAt,
+ };
+}
+
+function liveAgentEvent(sequence: number): RawSessionEvent {
+ return {
+ type: "session.event",
+ protocol_version: 1,
+ event_type: "message.added",
+ session_id: "session-1",
+ task_id: "task-1",
+ sequence,
+ event_id: `event-${sequence}`,
+ payload: {
+ type: "message.added",
+ session_id: "session-1",
+ task_id: "task-1",
+ message_id: "message-live",
+ author_type: "agent",
+ content: "live",
+ message_type: "message",
+ created_at: "2026-09-07T12:02:00Z",
+ },
+ };
+}
+
+function liveMessageDeletedEvent(sequence: number, messageId: string): RawSessionEvent {
+ return {
+ type: "session.event",
+ protocol_version: 1,
+ event_type: "message.deleted",
+ session_id: "session-1",
+ task_id: "task-1",
+ sequence,
+ event_id: `event-${sequence}`,
+ payload: {
+ type: "message.deleted",
+ session_id: "session-1",
+ task_id: "task-1",
+ message_id: messageId,
+ },
+ };
+}
+
+function response(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function renderScope(taskId: string, sessionId: string, testId: string) {
+ return (
+
+
+
+ );
+}
+
+function resetHarness() {
+ transport.request.mockReset();
+ transport.listener = null;
+ userQueryState = null;
+ agentQueryState = null;
+ ascendingState = null;
+ transport.request.mockResolvedValue({
+ success: true,
+ snapshot_token: "snapshot-1",
+ resume_token: "resume-1",
+ consumer_id: "consumer-1",
+ event_watermark: 0,
+ expires_at: FAR_FUTURE,
+ result: "fresh",
+ });
+ 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,
+ }),
+ );
+ }
+ return Promise.resolve(response({ messages: [], hasMore: false, cursor: null }));
+ }),
+ );
+}
+
+function registerIsolationLifecycle() {
+ beforeEach(resetHarness);
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+}
+
+describe("plugin conversation panel isolation", () => {
+ registerIsolationLifecycle();
+
+ it("gives concurrent panels independent consumer identities and snapshots", async () => {
+ render(
+ <>
+ {renderScope("task-1", "session-1", "panel-one")}
+ {renderScope("task-2", "session-2", "panel-two")}
+ >,
+ );
+
+ await waitFor(() => expect(screen.getByTestId("panel-one").textContent).toBe("true"));
+ await waitFor(() => expect(screen.getByTestId("panel-two").textContent).toBe("true"));
+ const subscriptions = transport.request.mock.calls.filter(
+ ([action]) => action === "session.subscribe",
+ );
+ expect(subscriptions).toHaveLength(2);
+ expect(subscriptions[0][1].consumer_id).not.toBe(subscriptions[1][1].consumer_id);
+ const fetchMock = vi.mocked(fetch);
+ expect(fetchMock.mock.calls.some(([input]) => String(input).includes("session-1"))).toBe(true);
+ expect(fetchMock.mock.calls.some(([input]) => String(input).includes("session-2"))).toBe(true);
+ });
+});
+
+describe("message query snapshot isolation", () => {
+ registerIsolationLifecycle();
+
+ it("buffers live events until every distinct message query commits its snapshot", async () => {
+ const userPage = deferred();
+ const agentPage = deferred();
+ vi.stubGlobal(
+ "fetch",
+ 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,
+ }),
+ );
+ }
+ return url.includes("author_type=user") ? userPage.promise : agentPage.promise;
+ }),
+ );
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3));
+
+ await act(async () => {
+ userPage.resolve(
+ response({
+ messages: [message("message-user", "user snapshot", SNAPSHOT_CREATED_AT, "user")],
+ hasMore: false,
+ cursor: null,
+ }),
+ );
+ await userPage.promise;
+ });
+ act(() => transport.listener?.(liveAgentEvent(1)));
+
+ expect(screen.getByTestId("agent-query").textContent).toBe("");
+ expect(transport.request.mock.calls.filter(([action]) => action === "session.ack")).toEqual([]);
+
+ await act(async () => {
+ agentPage.resolve(
+ response({
+ messages: [message("message-agent", "agent snapshot", "2026-09-07T12:01:00Z", "agent")],
+ hasMore: false,
+ cursor: null,
+ }),
+ );
+ await agentPage.promise;
+ });
+ expect(screen.getByTestId("agent-query").textContent).toBe("agent snapshot|live");
+ await waitFor(() =>
+ expect(transport.request.mock.calls.some(([action]) => action === "session.ack")).toBe(true),
+ );
+ });
+});
+
+// eslint-disable-next-line max-lines-per-function -- This describe block keeps concurrent query recovery fixtures together.
+describe("message continuation isolation", () => {
+ registerIsolationLifecycle();
+
+ // eslint-disable-next-line max-lines-per-function -- This scenario covers concurrent queries across the complete expiry rebind.
+ it("renews concurrent continuations independently for distinct message queries", async () => {
+ transport.request.mockImplementation((action: string) =>
+ Promise.resolve(
+ action === "session.subscribe"
+ ? {
+ success: true,
+ snapshot_token: "snapshot-expiring",
+ resume_token: "resume-1",
+ consumer_id: "consumer-1",
+ event_watermark: 0,
+ expires_at: new Date(Date.now() + 30_000).toISOString(),
+ result: "fresh",
+ }
+ : { success: true },
+ ),
+ );
+ const renewalCursors: string[] = [];
+ const requestedPageCursors: string[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith(BINDING_PATH_SUFFIX)) {
+ return Promise.resolve(
+ response({
+ bindingToken: "binding-1",
+ generation: 7,
+ expiresAt: FAR_FUTURE,
+ }),
+ );
+ }
+ if (url.endsWith("/conversation/continuation/renew")) {
+ const body = JSON.parse(String(init?.body)) as { cursor: string };
+ renewalCursors.push(body.cursor);
+ return Promise.resolve(
+ response({
+ cursor: `renewed-${body.cursor}`,
+ snapshot_token: `snapshot-${body.cursor}`,
+ expires_at: FAR_FUTURE,
+ }),
+ );
+ }
+ const parsed = new URL(url);
+ const author = parsed.searchParams.get("author_type");
+ const cursor = parsed.searchParams.get("cursor");
+ if (cursor) requestedPageCursors.push(cursor);
+ return Promise.resolve(
+ response({
+ messages: cursor
+ ? []
+ : [
+ message(
+ `message-${author}`,
+ `${author} snapshot`,
+ SNAPSHOT_CREATED_AT,
+ author === "agent" ? "agent" : "user",
+ ),
+ ],
+ hasMore: !cursor,
+ cursor: cursor ? null : `cursor-${author}`,
+ }),
+ );
+ }),
+ );
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(userQueryState?.hasMore).toBe(true);
+ expect(agentQueryState?.hasMore).toBe(true);
+ });
+
+ await act(async () => {
+ await Promise.all([userQueryState!.loadMore(), agentQueryState!.loadMore()]);
+ });
+
+ expect(renewalCursors.sort()).toEqual(["cursor-agent", "cursor-user"]);
+ expect(requestedPageCursors.sort()).toEqual(["renewed-cursor-agent", "renewed-cursor-user"]);
+ });
+
+ it("joins expiry recovery while preserving each query's continuation", async () => {
+ let subscribeCount = 0;
+ transport.request.mockImplementation((action: string) => {
+ if (action === "session.subscribe") {
+ 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,
+ result: "fresh",
+ });
+ }
+ return Promise.resolve({ success: true });
+ });
+ const pageCount = new Map();
+ const continuationCursors: string[] = [];
+ 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 }),
+ );
+ }
+ const parsed = new URL(url);
+ const author = parsed.searchParams.get("author_type") ?? "none";
+ const cursor = parsed.searchParams.get("cursor");
+ if (cursor) continuationCursors.push(cursor);
+ const count = (pageCount.get(author) ?? 0) + 1;
+ pageCount.set(author, count);
+ if (cursor) {
+ return Promise.resolve(
+ response({
+ messages: [
+ message(
+ `older-${author}`,
+ `${author} older`,
+ SNAPSHOT_CREATED_AT,
+ author === "agent" ? "agent" : "user",
+ ),
+ ],
+ hasMore: false,
+ cursor: null,
+ }),
+ );
+ }
+ return Promise.resolve(
+ response({
+ messages: [
+ message(
+ `current-${author}`,
+ `${author} current ${count}`,
+ SNAPSHOT_CREATED_AT,
+ author === "agent" ? "agent" : "user",
+ ),
+ ],
+ hasMore: true,
+ cursor: `${author}-cursor-${count === 1 ? "expired" : "fresh"}`,
+ }),
+ );
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(userQueryState?.hasMore).toBe(true);
+ expect(agentQueryState?.hasMore).toBe(true);
+ });
+
+ await act(async () => {
+ await Promise.all([userQueryState!.loadMore(), agentQueryState!.loadMore()]);
+ });
+
+ expect(subscribeCount).toBe(2);
+ expect(continuationCursors.sort()).toEqual(["agent-cursor-fresh", "user-cursor-fresh"]);
+ expect(screen.getByTestId("user-query").textContent).toContain("user older");
+ expect(screen.getByTestId("agent-query").textContent).toContain("agent older");
+ expect(
+ fetchMock.mock.calls.some(([input]) => String(input).includes("continuation/renew")),
+ ).toBe(false);
+ });
+});
+
+describe("ascending message pagination", () => {
+ registerIsolationLifecycle();
+
+ it("deduplicates and re-sorts an ascending list after live delivery then loadMore", async () => {
+ let messagePage = 0;
+ vi.stubGlobal(
+ "fetch",
+ 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,
+ }),
+ );
+ }
+ messagePage += 1;
+ return Promise.resolve(
+ response(
+ messagePage === 1
+ ? {
+ messages: [message("message-base", "base", SNAPSHOT_CREATED_AT, "agent")],
+ hasMore: true,
+ cursor: "cursor-1",
+ }
+ : {
+ messages: [
+ message("message-live", "stale page", "2026-09-07T12:02:00Z", "agent"),
+ message("message-middle", "middle", "2026-09-07T12:01:00Z", "agent"),
+ ],
+ hasMore: false,
+ cursor: null,
+ },
+ ),
+ );
+ }),
+ );
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(ascendingState?.hasMore).toBe(true));
+ act(() => transport.listener?.(liveAgentEvent(1)));
+ expect(screen.getByTestId("ascending-query").textContent).toBe("base|live");
+
+ await act(async () => {
+ await ascendingState!.loadMore();
+ });
+
+ expect(screen.getByTestId("ascending-query").textContent).toBe("base|middle|live");
+ });
+});
+describe("message continuation deletion isolation", () => {
+ registerIsolationLifecycle();
+
+ it("replays deletions received during continuation before committing the page", async () => {
+ const continuationPage = deferred();
+ let messagePage = 0;
+ vi.stubGlobal(
+ "fetch",
+ 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,
+ }),
+ );
+ }
+ messagePage += 1;
+ if (messagePage === 1) {
+ return Promise.resolve(
+ response({
+ messages: [message("message-base", "base", SNAPSHOT_CREATED_AT, "agent")],
+ hasMore: true,
+ cursor: "cursor-1",
+ }),
+ );
+ }
+ return continuationPage.promise;
+ }),
+ );
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(ascendingState?.hasMore).toBe(true));
+
+ let loadMore!: Promise;
+ await act(async () => {
+ loadMore = ascendingState!.loadMore();
+ await waitFor(() => expect(messagePage).toBe(2));
+ });
+ act(() => transport.listener?.(liveMessageDeletedEvent(1, "message-page")));
+ continuationPage.resolve(
+ response({
+ messages: [message("message-page", "deleted page row", "2026-09-07T12:01:00Z", "agent")],
+ hasMore: false,
+ cursor: null,
+ }),
+ );
+ await act(async () => {
+ await loadMore;
+ });
+
+ expect(screen.getByTestId("ascending-query").textContent).toBe("base");
+ });
+});
diff --git a/apps/web/lib/plugins/conversation-host.test.tsx b/apps/web/lib/plugins/conversation-host.test.tsx
new file mode 100644
index 0000000000..760b12178c
--- /dev/null
+++ b/apps/web/lib/plugins/conversation-host.test.tsx
@@ -0,0 +1,1272 @@
+/* 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 (
+
+
+ {currentState.messages.map((message) => message.content).join("|")}
+
+ {String(currentState.removed)}
+ {currentState.error?.code ?? ""}
+
+ );
+}
+
+function TurnsHarness({ sessionId, taskId }: { sessionId: string | null; taskId?: string | null }) {
+ currentTurnsState = pluginConversationApi.useSessionTurns(sessionId, taskId);
+ return (
+
+ {currentTurnsState.turns.map((turn) => turn.id).join("|")}
+ {String(currentTurnsState.removed)}
+ {currentTurnsState.error?.code ?? ""}
+
+ );
+}
+
+function renderTurnsHarness(sessionId: string | null, taskId?: string | null) {
+ return render(
+
+
+ ,
+ );
+}
+
+function renderHarness(sessionId: string | null, taskId?: string | null) {
+ return render(
+
+
+ ,
+ );
+}
+
+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 {
+ 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,
+ 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((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((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((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((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((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,
+ ),
+ );
+ });
+});
diff --git a/apps/web/lib/plugins/conversation-host.tsx b/apps/web/lib/plugins/conversation-host.tsx
new file mode 100644
index 0000000000..0dbbe8dcbe
--- /dev/null
+++ b/apps/web/lib/plugins/conversation-host.tsx
@@ -0,0 +1,896 @@
+/* eslint-disable max-lines -- The facade hook centralizes ordered snapshot/live state coordination. */
+"use client";
+
+import * as React from "react";
+import { useMessageFavoritesStore } from "@/lib/state/slices/message-favorites";
+import {
+ compareConversationMessages,
+ eventMatchesTask,
+ eventString,
+ messageFromEvent,
+ turnFromEvent,
+} from "./conversation-event-projection";
+import type {
+ PluginConversationApi,
+ PluginConversationError,
+ PluginConversationMessage,
+ PluginConversationTurn,
+ PluginSessionMessagesQuery,
+ PluginSessionMessagesState,
+ PluginSessionTurnsState,
+} from "./types";
+import {
+ ConversationScopeContext,
+ parseConversationResponse,
+ pluginConversationUrl,
+ type ConversationScope,
+} from "./conversation-scope";
+export { PluginConversationScopeProvider } from "./conversation-scope";
+
+type MessagePage = {
+ messages: PluginConversationMessage[];
+ hasMore: boolean;
+ cursor: string | null;
+};
+
+type TurnsPage = { turns: PluginConversationTurn[] };
+
+const EMPTY_MESSAGES: PluginSessionMessagesState = {
+ messages: [],
+ loading: false,
+ hydrated: false,
+ loadingMore: false,
+ error: null,
+ hasMore: false,
+ removed: false,
+ loadMore: async () => 0,
+ retry: () => undefined,
+};
+
+const EMPTY_TURNS: PluginSessionTurnsState = {
+ turns: [],
+ loading: false,
+ hydrated: false,
+ error: null,
+ removed: false,
+ retry: () => undefined,
+};
+
+function conversationError(error: unknown): PluginConversationError {
+ if (typeof error === "object" && error !== null && "code" in error) {
+ return error as PluginConversationError;
+ }
+ return {
+ code: "upstream_failure",
+ // i18n-exempt: plugin-facing transport diagnostic; plugins render their own localized error UI.
+ message: error instanceof Error ? error.message : "Conversation request failed",
+ retryable: true,
+ };
+}
+
+function isRemovedConversationError(error: PluginConversationError): boolean {
+ return error.code === "not_found";
+}
+
+function resolveTaskId(
+ scope: ConversationScope,
+ taskId: string | null | undefined,
+): { taskId: string | null; error: PluginConversationError | null } {
+ const resolved = taskId === undefined ? scope.taskId : taskId;
+ if (resolved === "" || (typeof resolved === "string" && resolved !== scope.taskId)) {
+ return {
+ taskId: null,
+ error: {
+ code: "invalid_query",
+ // i18n-exempt: plugin-facing programmer diagnostic; plugins render their own localized error UI.
+ message: "taskId must match the active task",
+ retryable: false,
+ },
+ };
+ }
+ return { taskId: resolved, error: null };
+}
+
+type MessagesState = Omit;
+type MessagesSetter = React.Dispatch>;
+
+// Session deletion emits child tombstones before its terminal marker. Keep
+// rows removed by that ordered teardown so the terminal view can retain the
+// committed conversation state.
+function restoreDeletedMessages(
+ current: readonly PluginConversationMessage[],
+ deleted: ReadonlyMap,
+ sort: "asc" | "desc",
+): PluginConversationMessage[] {
+ const messages = [...current];
+ const currentIds = new Set(messages.map((message) => message.id));
+ for (const message of deleted.values()) {
+ if (currentIds.has(message.id)) continue;
+ messages.push(message);
+ }
+ messages.sort((left, right) => compareConversationMessages(left, right, sort));
+ return messages;
+}
+
+function useOrderedMessageEvents({
+ scope,
+ sessionId,
+ taskId,
+ authorsKey,
+ sort,
+ snapshotKey,
+ setState,
+ cursorRef,
+ messagesRef,
+ deletedMessagesRef,
+}: {
+ scope: ConversationScope | null;
+ sessionId: string | null;
+ taskId: string | null;
+ authorsKey: string;
+ sort: "asc" | "desc";
+ snapshotKey: string;
+ setState: MessagesSetter;
+ cursorRef: React.MutableRefObject;
+ messagesRef: React.MutableRefObject;
+ deletedMessagesRef: React.MutableRefObject