@@ -1546,6 +1634,9 @@ function PullRequestsRouteView() {
rightPanelOpen: rightPanelState.isOpen,
maximizedAway: rightPanelMaximized,
listBody,
+ listItems,
+ renderListItem,
+ listLocked: closeSweepKeys.size > 0,
scrollRef,
};
@@ -2309,6 +2400,9 @@ function PullRequestsColumn({
rightPanelOpen,
maximizedAway,
listBody,
+ listItems,
+ renderListItem,
+ listLocked,
scrollRef,
}: {
refreshing: boolean;
@@ -2329,9 +2423,15 @@ function PullRequestsColumn({
rightPanelOpen: boolean;
maximizedAway: boolean;
listBody: ReactNode;
+ listItems: ReadonlyArray
;
+ renderListItem: (info: { item: PullRequestListItem }) => ReactNode;
+ listLocked: boolean;
scrollRef: RefObject;
}) {
const markerRef = useRef(null);
+ const attachList = (list: LegendListRef | null) => {
+ scrollRef.current = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
+ };
const [condensed, setCondensed] = useState(false);
useEffect(() => {
const marker = markerRef.current;
@@ -2448,38 +2548,59 @@ function PullRequestsColumn({
{rightPanelControl}
-
-
-
-
-
- {searchInput}
+ {/* Only the rows in view render; the controls scroll with them as the list header. */}
+
+ ref={attachList}
+ data={listItems}
+ keyExtractor={pullRequestListItemKey}
+ getItemType={pullRequestListItemType}
+ renderItem={renderListItem}
+ extraData={renderListItem}
+ estimatedItemSize={56}
+ recycleItems={false}
+ className={cn(
+ "topbar-scroll-fade scrollbar-gutter-both min-h-0 flex-1 overflow-x-hidden",
+ listLocked && "**:pointer-events-none",
+ )}
+ // The footer fills what the rows leave, so the empty states stay centered.
+ contentContainerClassName="flex flex-col [&>:last-child]:flex [&>:last-child]:flex-1 [&>:last-child]:flex-col"
+ ListHeaderComponent={
+
+
+
+
+ {searchInput}
+
+ {sortMenu}
+ {filtersMenu}
+
}
+ triggerLabel="All"
+ value={host ?? ""}
+ options={hostMenuOptions}
+ onChange={(next) => onHost(next === "" ? undefined : next)}
+ />
+ {!condensed ? (
+
+ ) : null}
- {sortMenu}
- {filtersMenu}
-
}
- triggerLabel="All"
- value={host ?? ""}
- options={hostMenuOptions}
- onChange={(next) => onHost(next === "" ? undefined : next)}
- />
- {!condensed ? (
-
- ) : null}
+ {/* Scrolled past this marker, the controls are gone and the title takes over. */}
+
-
-
-
- {listBody}
-
-
+
+ }
+ ListFooterComponent={
+
0 ? "pt-4" : "pt-0")}
+ >
+ {listBody}
+
+ }
+ />
);
}
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index 53c11954cec61..ba72de9b71ebb 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -1,10 +1,11 @@
import { AuthPreviewOperateScope } from "@t3tools/contracts";
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { useAtomValue } from "@effect/atom-react";
-import { useEffect } from "react";
+import { useEffect, useLayoutEffect } from "react";
import { isCommandPaletteOpen } from "../commandPaletteBus";
-import { ThreadRouteView } from "../components/ThreadRouteView";
+import { registerChatSurface } from "../components/chatSurfaceSlot";
+import { KeptChatSurface } from "../components/ThreadRouteView";
import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt";
import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
import { openCommandPalette } from "../commandPaletteBus";
@@ -206,16 +207,16 @@ function ChatRouteGlobalShortcuts() {
}
function ChatRouteLayout() {
- // Both thread routes render here, not in their own leaf components, so the
- // draft-to-thread promotion keeps one ChatView mounted across the swap.
const threadTarget = useThreadRouteTarget();
+ // Root owns the chat view so it survives route swaps.
+ useLayoutEffect(() => registerChatSurface(), []);
return (
<>
{threadTarget?.kind === "server" ? (
) : null}
- {threadTarget ? : }
+
>
);
}
diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts
index a12dfde6e0bcd..7d54510c49bb5 100644
--- a/apps/web/src/state/server.ts
+++ b/apps/web/src/state/server.ts
@@ -84,6 +84,14 @@ export const primaryServerConfigEventAtom = Atom.make(
(get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent,
).pipe(Atom.withLabel("web-primary-server-config-event"));
+/** The primary server's first config this session. */
+export const primaryServerSessionConfigAtom = Atom.make((get): ServerConfig | null => {
+ const environmentId = get(primaryEnvironmentIdAtom);
+ return environmentId === null
+ ? null
+ : get(environmentSession.initialConfigValueAtom(environmentId));
+}).pipe(Atom.withLabel("web-primary-server-session-config"));
+
export const primaryServerWelcomeAtom = Atom.make(
(get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome,
).pipe(Atom.withLabel("web-primary-server-welcome"));
diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts
index e594e8568830c..0604bcd620072 100644
--- a/apps/web/src/state/session.ts
+++ b/apps/web/src/state/session.ts
@@ -33,6 +33,16 @@ export function useEnvironmentScope(
return sessionHasScope(result, scope);
}
+/** True until the environment's session has answered once, so grants are unknown. */
+export function useEnvironmentSessionPending(environmentId: EnvironmentId | null): boolean {
+ const result = useAtomValue(
+ environmentId === null
+ ? EMPTY_SESSION_STATE_ATOM
+ : environmentSession.sessionStateAtom(environmentId),
+ );
+ return result._tag !== "Failure" && Option.isNone(AsyncResult.value(result));
+}
+
function sessionHasScope(
result: AsyncResult.AsyncResult,
scope: AuthEnvironmentScope,
diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts
index b89e8dd942eba..a6b44c99c1839 100644
--- a/apps/web/src/threadRoutes.test.ts
+++ b/apps/web/src/threadRoutes.test.ts
@@ -1,4 +1,12 @@
-import { describe, expect, it } from "vite-plus/test";
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import {
+ eraseHeldText,
+ holdTextForUrlRouteTarget,
+ isUrlTargetSidebarTextKey,
+ setUrlRouteTargetKey,
+ insertHeldText,
+} from "./components/keptView";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { ThreadId } from "@t3tools/contracts";
import { DraftId } from "./composerDraftStore";
@@ -9,11 +17,93 @@ import {
isThreadRoutePathname,
isThreadRouteSnapshotAuthoritative,
resolveActiveThreadRouteRef,
+ finishWarmingThreadView,
+ keepReleasedThreadView,
+ resolveKeptThreadViews,
resolveThreadRouteRenderState,
resolveThreadRouteRef,
resolveThreadRouteTarget,
+ type KeptThreadView,
} from "./threadRoutes";
+describe("sidebar typing during a kept thread navigation", () => {
+ const takeHeldText = (key: string) => {
+ let taken: string | null = null;
+ insertHeldText(key, (text) => {
+ taken = text;
+ return true;
+ });
+ return taken;
+ };
+ const previous = "server:env-1:previous";
+ const destination = "server:env-1:destination";
+ const item = document.createElement("li");
+ item.setAttribute("data-thread-item", "env-1:destination");
+ item.innerHTML =
+ '';
+ const row = item.querySelector("[role=button]")!;
+
+ afterEach(() => {
+ takeHeldText(destination);
+ setUrlRouteTargetKey(null);
+ vi.restoreAllMocks();
+ });
+
+ it("keeps destination text until its composer accepts it, even after a long connection", () => {
+ const clock = vi.spyOn(performance, "now").mockReturnValue(0);
+ setUrlRouteTargetKey(destination);
+ holdTextForUrlRouteTarget(previous, "draft");
+ const insert = vi.fn().mockReturnValue(false);
+ insertHeldText(destination, insert);
+ clock.mockReturnValue(5_000);
+ insertHeldText(destination, insert);
+ expect(insert.mock.calls).toEqual([["draft"], ["draft"]]);
+ expect(takeHeldText(previous)).toBeNull();
+ insert.mockReturnValue(true);
+ insertHeldText(destination, insert);
+ expect(takeHeldText(destination)).toBeNull();
+ expect(insert).toHaveBeenCalledTimes(3);
+ });
+
+ it("holds destination row typing without giving any text to the previous draft", () => {
+ setUrlRouteTargetKey(destination);
+ const key = new KeyboardEvent("keydown", { key: "A", shiftKey: true });
+ row.dispatchEvent(key);
+ expect(isUrlTargetSidebarTextKey(key)).toBe(true);
+ expect(holdTextForUrlRouteTarget(previous, key.key)).toBe(true);
+ expect(takeHeldText(previous)).toBeNull();
+ holdTextForUrlRouteTarget(previous, "b");
+ expect(eraseHeldText(previous)).toBe(true);
+ holdTextForUrlRouteTarget(previous, "c");
+ expect(takeHeldText(destination)).toBe("Ac");
+ expect(takeHeldText(destination)).toBeNull();
+ setUrlRouteTargetKey(previous);
+ expect(isUrlTargetSidebarTextKey(key)).toBe(false);
+ setUrlRouteTargetKey(destination);
+ expect(holdTextForUrlRouteTarget(destination, "a")).toBe(false);
+ row.querySelector("button")!.dispatchEvent(key);
+ expect(isUrlTargetSidebarTextKey(key)).toBe(false);
+ expect(takeHeldText(destination)).toBeNull();
+ });
+
+ it.each([
+ { key: " " },
+ { key: "Enter" },
+ { key: "Tab" },
+ { key: "ArrowDown" },
+ { key: "a", ctrlKey: true },
+ { key: "a", metaKey: true },
+ { key: "a", altKey: true },
+ { key: "a", isComposing: true },
+ ])("leaves activation, navigation, modifiers and composition alone: %j", (init) => {
+ setUrlRouteTargetKey(destination);
+ const key = new KeyboardEvent("keydown", init);
+ row.dispatchEvent(key);
+ expect(isUrlTargetSidebarTextKey(key)).toBe(false);
+ expect(takeHeldText(destination)).toBeNull();
+ });
+});
+
describe("threadRoutes", () => {
it("does not treat cached thread lists as authoritative for deep links", () => {
expect(isThreadRouteSnapshotAuthoritative("empty")).toBe(false);
@@ -206,3 +296,85 @@ describe("threadRoutes", () => {
).toBe("loading");
});
});
+
+describe("resolveKeptThreadViews", () => {
+ const serverView = (id: string): Omit => ({
+ key: `env-1:${id}`,
+ threadKey: `env-1:${id}`,
+ environmentId: "env-1" as never,
+ threadId: ThreadId.make(id),
+ draftId: null,
+ });
+ // Opens each thread in turn, keeping released threads and finishing their
+ // warm-up right away, as idle time would.
+ const visit = (ids: ReadonlyArray, limit?: number) =>
+ ids.reduce>((views, id) => {
+ const update = resolveKeptThreadViews(views, serverView(id), limit);
+ if (update.released === null) return update.views;
+ const kept = keepReleasedThreadView(update.views, update.released, limit);
+ const warming = kept.find((view) => view.warming === true);
+ return warming === undefined ? kept : finishWarmingThreadView(kept, warming.key);
+ }, []);
+
+ it("returns the same list when the shown thread is shown again", () => {
+ const views = visit(["a"]);
+ expect(resolveKeptThreadViews(views, serverView("a")).views).toBe(views);
+ });
+
+ it("opens a new thread in the shown view and releases the thread it showed", () => {
+ const first = resolveKeptThreadViews([], serverView("a")).views;
+ const update = resolveKeptThreadViews(first, serverView("b"));
+ expect(update.views.map((view) => [view.key, view.threadId])).toEqual([
+ ["view:env-1:a:1", "b"],
+ ]);
+ expect(update.released?.threadId).toBe("a");
+ });
+
+ it("keeps a released thread in a warming view, then reveals it on a revisit", () => {
+ const first = resolveKeptThreadViews([], serverView("a")).views;
+ const update = resolveKeptThreadViews(first, serverView("b"));
+ const kept = keepReleasedThreadView(update.views, update.released!);
+ expect(kept.map((view) => [view.threadId, view.warming === true])).toEqual([
+ ["b", false],
+ ["a", true],
+ ]);
+ const revisit = resolveKeptThreadViews(kept, serverView("a"));
+ expect(revisit.released).toBeNull();
+ expect(revisit.views.map((view) => view.key)).toEqual(kept.map((view) => view.key));
+ expect(revisit.views.find((view) => view.threadId === "a")?.warming).toBe(false);
+ });
+
+ it("drops the least recently shown hidden view to keep a released thread in a full pool", () => {
+ const views = visit(["a", "b", "c"], 3);
+ expect(views.map((view) => view.threadId).toSorted()).toEqual(["a", "b", "c"]);
+ const next = visit(["a", "b", "c", "d"], 3);
+ expect(next.map((view) => view.threadId).toSorted()).toEqual(["b", "c", "d"]);
+ });
+
+ it("skips keeping a released thread that is shown again", () => {
+ const first = resolveKeptThreadViews([], serverView("a")).views;
+ const update = resolveKeptThreadViews(first, serverView("b"));
+ const back = resolveKeptThreadViews(update.views, serverView("a"));
+ expect(keepReleasedThreadView(back.views, update.released!)).toBe(back.views);
+ });
+
+ it("keeps a promoted draft's view, and its key, on the thread route", () => {
+ const draftId = DraftId.make("draft-1");
+ const draft = resolveKeptThreadViews([], { ...serverView("t"), key: draftId, draftId }).views;
+ const promoted = resolveKeptThreadViews(draft, serverView("t")).views;
+ expect(promoted).toHaveLength(1);
+ expect(promoted[0]).toMatchObject({ key: draftId, draftId: null, threadId: "t" });
+ });
+
+ it("drops a draft's view once another view is shown", () => {
+ const draftId = DraftId.make("draft-1");
+ const views = resolveKeptThreadViews(visit(["a"]), {
+ ...serverView("t"),
+ key: draftId,
+ draftId,
+ }).views;
+ expect(
+ resolveKeptThreadViews(views, serverView("a")).views.map((view) => view.threadId),
+ ).toEqual(["a"]);
+ });
+});
diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts
index 20b3f7396dbf1..4130cf0e53df6 100644
--- a/apps/web/src/threadRoutes.ts
+++ b/apps/web/src/threadRoutes.ts
@@ -1,4 +1,8 @@
-import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ parseScopedThreadKey,
+ scopeThreadRef,
+ scopedThreadKey,
+} from "@t3tools/client-runtime/environment";
import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import type { DraftId } from "./composerDraftStore";
@@ -94,6 +98,28 @@ export function resolveThreadRouteTarget(
};
}
+export function threadRouteTargetKey(target: ThreadRouteTarget | null): string | null {
+ if (target === null) return null;
+ return target.kind === "server"
+ ? `server:${scopedThreadKey(target.threadRef)}`
+ : `draft:${target.draftId}`;
+}
+
+export function threadRouteTargetFromKey(key: string | null): ThreadRouteTarget | null {
+ if (key === null) return null;
+ if (key.startsWith("draft:")) {
+ return { kind: "draft", draftId: key.slice("draft:".length) as DraftId };
+ }
+ const threadRef = key.startsWith("server:")
+ ? parseScopedThreadKey(key.slice("server:".length))
+ : null;
+ return threadRef === null ? null : { kind: "server", threadRef };
+}
+
+/**
+ * Resolves the thread represented by either a canonical thread route or a
+ * draft route whose promotion to a server thread has been recorded.
+ */
export function resolveActiveThreadRouteRef(
target: ThreadRouteTarget | null,
draftThread: DraftThreadRouteState | null,
@@ -106,3 +132,146 @@ export function resolveActiveThreadRouteRef(
}
return draftThread.promotedTo;
}
+
+export interface KeptThreadView {
+ /** Promotion preserves the draft's React key. */
+ readonly key: string;
+ readonly threadKey: string;
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ /** Draft views are dropped once left. */
+ readonly draftId: DraftId | null;
+ readonly visit: number;
+ /** Lay out a released thread out of sight before hiding it. */
+ readonly warming?: boolean;
+}
+
+const KEPT_THREAD_VIEW_LIMIT = 4;
+
+function retarget(
+ view: KeptThreadView,
+ active: Omit,
+ visit: number,
+): KeptThreadView {
+ return {
+ ...view,
+ environmentId: active.environmentId,
+ threadId: active.threadId,
+ threadKey: active.threadKey,
+ draftId: active.draftId,
+ visit,
+ };
+}
+
+function leastRecentlyShown(views: ReadonlyArray): KeptThreadView | null {
+ return views.reduce(
+ (oldest, view) => (oldest !== null && oldest.visit <= view.visit ? oldest : view),
+ null,
+ );
+}
+
+export interface KeptThreadViewsUpdate {
+ readonly views: ReadonlyArray;
+ /** Replaced thread, for idle warming with keepReleasedThreadView. */
+ readonly released: KeptThreadView | null;
+}
+
+// Reuse the shown view on first opens to avoid mounting on the click path.
+// Preserve entry order: moving rendered DOM loses scroll positions.
+export function resolveKeptThreadViews(
+ current: ReadonlyArray,
+ active: Omit | null,
+ limit = KEPT_THREAD_VIEW_LIMIT,
+): KeptThreadViewsUpdate {
+ const unchanged = { views: current, released: null };
+ const lastVisit = current.reduce((max, view) => Math.max(max, view.visit), 0);
+ // Match promoted drafts by thread identity to preserve their view.
+ const existing =
+ active === null
+ ? undefined
+ : current.find((view) =>
+ active.draftId === null ? view.threadKey === active.threadKey : view.key === active.key,
+ );
+ const servers = current.filter((view) => view.draftId === null && view !== existing);
+ const same = (views: ReadonlyArray) =>
+ views.length === current.length && views.every((view, index) => view === current[index]);
+ if (active === null) {
+ return same(servers) ? unchanged : { views: servers, released: null };
+ }
+ if (existing !== undefined) {
+ const visit = existing.visit === lastVisit ? lastVisit : lastVisit + 1;
+ const refreshed =
+ existing.draftId === active.draftId &&
+ existing.environmentId === active.environmentId &&
+ existing.threadId === active.threadId &&
+ existing.visit === visit &&
+ existing.warming !== true
+ ? existing
+ : { ...retarget(existing, active, visit), warming: false };
+ const views = current.flatMap((view) =>
+ view === existing ? [refreshed] : view.draftId === null ? [view] : [],
+ );
+ return same(views) ? unchanged : { views, released: null };
+ }
+ const visit = lastVisit + 1;
+ const shown = current.find((view) => view.visit === lastVisit && view.warming !== true);
+ if (active.draftId === null) {
+ if (shown !== undefined && shown.draftId === null) {
+ return {
+ views: servers.map((view) => (view === shown ? retarget(view, active, visit) : view)),
+ released: shown,
+ };
+ }
+ const recycled = leastRecentlyShown(servers.filter((view) => view.warming !== true));
+ if (recycled !== null) {
+ return {
+ views: servers.map((view) => (view === recycled ? retarget(view, active, visit) : view)),
+ released: null,
+ };
+ }
+ }
+ let views: ReadonlyArray = [
+ ...servers,
+ { ...active, key: active.draftId ?? `view:${active.threadKey}:${visit}`, visit },
+ ];
+ while (views.length > limit) {
+ const evicted = leastRecentlyShown(views.slice(0, -1));
+ if (evicted === null) break;
+ views = views.filter((view) => view !== evicted);
+ }
+ return { views, released: null };
+}
+
+/** Warm released threads during idle time so revisits can reveal them. */
+export function keepReleasedThreadView(
+ current: ReadonlyArray,
+ released: KeptThreadView,
+ limit = KEPT_THREAD_VIEW_LIMIT,
+): ReadonlyArray {
+ if (limit < 2 || current.some((view) => view.threadKey === released.threadKey)) return current;
+ const lastVisit = current.reduce((max, view) => Math.max(max, view.visit), 0);
+ let views = current;
+ while (views.length >= limit) {
+ const evicted = leastRecentlyShown(views.filter((view) => view.visit !== lastVisit));
+ if (evicted === null) return current;
+ views = views.filter((view) => view !== evicted);
+ }
+ return [
+ ...views,
+ {
+ ...released,
+ key: `kept:${released.threadKey}:${released.visit}`,
+ draftId: null,
+ warming: true,
+ },
+ ];
+}
+
+export function finishWarmingThreadView(
+ current: ReadonlyArray,
+ key: string,
+): ReadonlyArray {
+ return current.some((view) => view.key === key && view.warming === true)
+ ? current.map((view) => (view.key === key ? { ...view, warming: false } : view))
+ : current;
+}
diff --git a/docs/internals/overview.md b/docs/internals/overview.md
index 149bcf57dff01..d68d2d6580fe1 100644
--- a/docs/internals/overview.md
+++ b/docs/internals/overview.md
@@ -115,6 +115,10 @@ rewriting an identical entry while the portal may be reading it. On Wayland, Ele
shortcut-registration result only confirms submission; it does not confirm desktop consent or
an active binding.
+The window opens before the login shell finishes. Main-process spawns must await
+`DesktopShellEnvironment.installIntoProcess` to inherit the user's PATH. The backend's
+`beforeSpawn` gate and spawning IPC handlers share that single import.
+
Native modules never load in the Electron main process on the startup path, and the two the
snapshot feature keeps are isolated: `@crowecawcaw/xa11y` runs only in forked Node-mode children
(`SnapShotAccessibilityWorker`, `RegionSnapShotWorker`) and a worker thread, and `ffi-rs` loads
diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts
index 77ce244aad5cf..d36a828230286 100644
--- a/packages/client-runtime/src/rpc/session.test.ts
+++ b/packages/client-runtime/src/rpc/session.test.ts
@@ -39,6 +39,7 @@ import * as RpcSession from "./session.ts";
import { makeEnvironmentServerConfigState } from "../state/server.ts";
import { applyServerConfigProjection } from "../state/serverConfigProjection.ts";
import { NETWORK_BLOCKING_HINT } from "../errors/network.ts";
+import { diffServerConfig } from "@t3tools/shared/serverConfigDelta";
type SocketEventType = "open" | "message" | "close" | "error";
type SocketEvent = {
@@ -288,6 +289,33 @@ const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents
});
describe("RpcSessionFactory", () => {
+ it("preserves sign-out field removals for legacy delta clients", () => {
+ const provider: ServerConfigType["providers"][number] = {
+ instanceId: ProviderInstanceId.make("codex"),
+ driver: ProviderDriverKind.make("codex"),
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-08-27T00:00:00Z",
+ models: [],
+ skills: [],
+ slashCommands: [],
+ usageLimits: { checkedAt: "2026-08-27T00:00:00Z", windows: [] },
+ };
+ const previous = { ...SERVER_CONFIG, providers: [provider] };
+ const next = { ...previous, providers: [{ ...provider, usageLimits: undefined }] };
+ const codec = Schema.toCodecJson(ServerConfigStreamEvent);
+ const wire = Schema.decodeSync(codec)(
+ Schema.encodeSync(codec)(diffServerConfig(previous, next, "signed-out")),
+ );
+ expect(wire).toMatchObject({
+ type: "configDelta",
+ providerChanges: [{ instanceId: provider.instanceId, removed: ["usageLimits"] }],
+ });
+ });
+
it.effect("owns one scoped websocket attempt and exposes readiness and closure", () =>
Effect.gen(function* () {
const { factory, sockets } = yield* makeFactory();
diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts
index dc6579217f3cc..8c0b9054a4727 100644
--- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts
+++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts
@@ -134,6 +134,7 @@ describe("boundedThreadSnapshotLoader", () => {
const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader;
const result = yield* loader.load(PREPARED, THREAD_ID);
expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1");
+ expect(new URL(urls[0]!).searchParams.get("compactCheckpointItems")).toBe("1");
expect(result._tag).toBe("present");
if (result._tag !== "present") return;
expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([
diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts
index 5a903226a3197..a643e96d33199 100644
--- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts
@@ -17,6 +17,12 @@ import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts";
const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
+/**
+ * Load a bounded recent-window thread snapshot over HTTP. Opts into compact
+ * turnItems and checkpoint items and restores them, so callers always see the
+ * full bounded shape.
+ * Older servers ignore the query and send the full shape.
+ */
export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
"clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot",
)(function* (input: {
@@ -30,7 +36,7 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
}) {
const endpoint = {
params: { threadId: input.threadId },
- query: { compactTurnItems: "1" },
+ query: { compactTurnItems: "1", compactCheckpointItems: "1" },
};
return yield* executeAuthenticatedEnvironmentHttpRequest({
...input,
@@ -44,9 +50,14 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
headers: withOrchestrationProtocolHeader(headers),
}),
}).pipe(
- Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({
+ // Drop the markers with the restore so nothing can restore twice.
+ Effect.map(({ turnItemsOmitLocalVisible, checkpointFilesOmittedItemIds, ...snapshot }) => ({
...snapshot,
- projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }),
+ projection: boundedSnapshotProjection({
+ ...snapshot,
+ turnItemsOmitLocalVisible,
+ checkpointFilesOmittedItemIds,
+ }),
})),
);
});
diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts
index f788e9e1fef76..38b16d5bc6e59 100644
--- a/packages/client-runtime/src/state/server.test.ts
+++ b/packages/client-runtime/src/state/server.test.ts
@@ -10,6 +10,7 @@ import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
+import * as Equal from "effect/Equal";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
@@ -459,6 +460,25 @@ describe("server state projection", () => {
expect(Option.getOrThrow(unpublished).config.environmentThemes).toBeUndefined();
});
+ it("keeps an equal config when a machine publishes no themes or usage sources", () => {
+ const snapshot = Option.getOrThrow(
+ applyServerConfigProjection(Option.none(), { version: 1, type: "snapshot", config: CONFIG }),
+ );
+ const themes = applyServerConfigProjection(Option.some(snapshot), {
+ version: 1,
+ type: "environmentThemesUpdated",
+ payload: { themes: [] },
+ });
+ const sources = applyServerConfigProjection(themes, {
+ version: 1,
+ type: "usageLimitSourcesUpdated",
+ payload: { sources: [] },
+ });
+ expect(Equal.equals(Option.getOrThrow(sources).config, snapshot.config)).toBe(true);
+ });
+
+ // A snapshot never carries published themes, so taking it wholesale would
+ // clear them on every reconnect and repaint anyone wearing one.
it("keeps published themes across a reconnect snapshot", () => {
const themes = [
{
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index d0cd59c56e968..19f551cb9b962 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -12,6 +12,7 @@ import * as Cause from "effect/Cause";
import * as Duration from "effect/Duration";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as Equal from "effect/Equal";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
@@ -585,6 +586,37 @@ function serverWelcomeStateChanges(environmentId: EnvironmentId) {
);
}
+// Avoid repeated config walks from hashing/schema equivalence during boot.
+function sameConfigValue(left: unknown, right: unknown): boolean {
+ if (Object.is(left, right)) return true;
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
+ return false;
+ }
+ if (Array.isArray(left)) {
+ if (!Array.isArray(right) || left.length !== right.length) return false;
+ for (let index = 0; index < left.length; index++) {
+ if (!sameConfigValue(left[index], right[index])) return false;
+ }
+ return true;
+ }
+ if (
+ Object.getPrototypeOf(left) !== Object.prototype ||
+ Object.getPrototypeOf(right) !== Object.prototype
+ ) {
+ return Equal.equals(left, right);
+ }
+ const leftRecord = left as Record;
+ const rightRecord = right as Record;
+ const keys = Object.keys(leftRecord);
+ if (keys.length !== Object.keys(rightRecord).length) return false;
+ for (const key of keys) {
+ if (!Object.hasOwn(rightRecord, key) || !sameConfigValue(leftRecord[key], rightRecord[key])) {
+ return false;
+ }
+ }
+ return true;
+}
+
export function resolveServerConfigValue(
projection: ServerConfigProjection | null,
initialConfig: ServerConfig | null,
@@ -652,7 +684,11 @@ export function createServerEnvironmentAtoms(
projection,
get(options.initialConfigValueAtom(environmentId)),
);
- }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
+ }).pipe(
+ // Cache/session/live handoffs must not rerender readers of an equal config.
+ Atom.withEquality(sameConfigValue),
+ Atom.withLabel(`environment-data:server:config:${environmentId}`),
+ );
});
const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) =>
Atom.make((get) =>
diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts
index 51efd4f7d2186..7e8b4897ac170 100644
--- a/packages/client-runtime/src/state/serverConfigProjection.ts
+++ b/packages/client-runtime/src/state/serverConfigProjection.ts
@@ -67,6 +67,10 @@ export function applyServerConfigProjection(
event: ServerConfigStreamEvent,
): Option.Option {
switch (event.type) {
+ // This client requests full events, so unsolicited delta acknowledgements carry no state.
+ case "configUnchanged":
+ case "configDelta":
+ return current;
case "snapshot": {
// Wire snapshots never contain published themes. Keep the previous set
// until a capable server sends its authoritative theme event. A legacy
@@ -120,23 +124,30 @@ export function applyServerConfigProjection(
latestEvent: event,
source: "live",
}));
+ // Match the cache's omitted empty sets to preserve config equality.
case "environmentThemesUpdated":
- return Option.map(current, (projection) => ({
- config: {
- ...projection.config,
- environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined,
- },
- latestEvent: event,
- source: "live",
- }));
+ return Option.map(current, (projection) => {
+ const { environmentThemes: _previous, ...config } = projection.config;
+ return {
+ config:
+ event.payload.themes.length > 0
+ ? { ...config, environmentThemes: event.payload.themes }
+ : config,
+ latestEvent: event,
+ source: "live",
+ };
+ });
case "usageLimitSourcesUpdated":
- return Option.map(current, (projection) => ({
- config: {
- ...projection.config,
- usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined,
- },
- latestEvent: event,
- source: "live",
- }));
+ return Option.map(current, (projection) => {
+ const { usageLimitSources: _previous, ...config } = projection.config;
+ return {
+ config:
+ event.payload.sources.length > 0
+ ? { ...config, usageLimitSources: event.payload.sources }
+ : config,
+ latestEvent: event,
+ source: "live",
+ };
+ });
}
}
diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts
index 2a62496b343d9..f397650c40834 100644
--- a/packages/client-runtime/src/state/session.ts
+++ b/packages/client-runtime/src/state/session.ts
@@ -1,7 +1,9 @@
import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
import * as Data from "effect/Data";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
+import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { HttpClient } from "effect/http";
@@ -37,6 +39,28 @@ function initialConfigOption(
const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000;
+// Retry transient startup failures before the next 30 s session revalidation.
+const SESSION_STATE_RETRIES = 5;
+const SESSION_STATE_RETRY_SCHEDULE = Schedule.exponential("500 millis").pipe(
+ Schedule.modifyDelay(({ duration }) =>
+ Effect.succeed(Duration.min(duration, Duration.seconds(4))),
+ ),
+);
+
+function isTransientSessionStateError(error: { readonly _tag: string }): boolean {
+ return (
+ error._tag === "RemoteEnvironmentAuthFetchError" ||
+ error._tag === "RemoteEnvironmentAuthTimeoutError" ||
+ error._tag === "RemoteEnvironmentAuthUndeclaredStatusError" ||
+ error._tag === "EnvironmentInternalError"
+ );
+}
+
+/**
+ * Read the granted scopes of this client's session on one environment via its
+ * `/api/auth/session` endpoint, using the connection's authentication method
+ * and refreshing relay credentials when needed.
+ */
export const fetchEnvironmentSessionState = Effect.fn(
"clientRuntime.state.fetchEnvironmentSessionState",
)(function* (input: {
@@ -138,7 +162,14 @@ function makeEnvironmentSessionAtoms(
prepared,
signer,
remoteAuthorization,
- }).pipe(Effect.provideService(HttpClient.HttpClient, client.value));
+ }).pipe(
+ Effect.provideService(HttpClient.HttpClient, client.value),
+ Effect.retry({
+ schedule: SESSION_STATE_RETRY_SCHEDULE,
+ times: SESSION_STATE_RETRIES,
+ while: isTransientSessionStateError,
+ }),
+ );
});
})
.pipe(
diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts
index 5f783854ee559..4b0c55a3245c2 100644
--- a/packages/client-runtime/src/state/shell-sync.test.ts
+++ b/packages/client-runtime/src/state/shell-sync.test.ts
@@ -1,7 +1,7 @@
import {
EnvironmentId,
ORCHESTRATION_V2_WS_METHODS,
- type OrchestrationV2ShellSnapshot,
+ OrchestrationV2ShellSnapshot,
type OrchestrationV2ShellStreamItem,
ThreadId,
type ThreadPullRequestLink,
@@ -15,6 +15,7 @@ import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Scope from "effect/Scope";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as TestClock from "effect/testing/TestClock";
@@ -31,6 +32,7 @@ import * as RpcSession from "../rpc/session.ts";
import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
import { makeEnvironmentShellState } from "./shell.ts";
import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts";
+import { decodeShellSnapshotDeferringPullRequests } from "./shellPullRequests.ts";
import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
const TARGET = new PrimaryConnectionTarget({
@@ -53,6 +55,32 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationV2ShellSnapshot = {
...v2ShellSnapshot,
snapshotSequence: 1,
};
+const encodeShellSnapshotJson = Schema.encodeEffect(
+ Schema.toCodecJson(OrchestrationV2ShellSnapshot),
+);
+
+const sharedStack = {
+ kind: "native" as const,
+ id: "stack:shared",
+ number: 1,
+ url: "https://github.com/pingdotgg/t3code/stack/1",
+ base: "main",
+ layers: [7, 8, 9].map((number) => ({
+ number,
+ headBranch: `branch-${number}`,
+ state: "open" as const,
+ })),
+};
+const sharedStackLink: ThreadPullRequestLink = {
+ host: "github.com",
+ repository: "pingdotgg/t3code",
+ number: 7,
+ url: "https://github.com/pingdotgg/t3code/pull/7",
+ source: "agent",
+ linkedAt: "2026-06-20T00:00:00.000Z",
+ snapshot: null,
+ stack: sharedStack,
+};
function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
return {
@@ -66,6 +94,37 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession {
}
describe("environment shell synchronization", () => {
+ it.effect("restores shared native stacks after deferred HTTP decoding", () =>
+ Effect.gen(function* () {
+ const link = sharedStackLink;
+ const stack = sharedStack;
+ const expected = [
+ link,
+ { ...link, number: 8 },
+ { ...link, number: 9, stack: { ...stack, base: "older-base" } },
+ ];
+ const source = {
+ ...v2ShellSnapshot,
+ threads: [
+ {
+ ...v2ThreadShell,
+ pullRequests: [
+ link,
+ { ...link, number: 8, stack: null, stackReference: 0 },
+ expected[2]!,
+ ],
+ },
+ ],
+ };
+ const json = yield* encodeShellSnapshotJson(source);
+ const decoded = yield* decodeShellSnapshotDeferringPullRequests(json);
+ expect(decoded.threads[0]?.pullRequests).toBeUndefined();
+ const loading = yield* decoded.loadPullRequests.pipe(Effect.forkScoped);
+ yield* TestClock.adjust("1 millis");
+ const restored = yield* Fiber.join(loading);
+ expect(restored.get(v2ThreadShell.id)).toEqual(expected);
+ }),
+ );
it.effect("publishes live state before persistence and preserves it when ready", () =>
Effect.gen(function* () {
const events = yield* Queue.unbounded();
@@ -153,6 +212,23 @@ describe("environment shell synchronization", () => {
const state = yield* SubscriptionRef.get(shellState);
expect(state.status).toBe("live");
expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT);
+ const link = sharedStackLink;
+ yield* Queue.offer(events, {
+ kind: "thread.updated",
+ sequence: 2,
+ location: "active",
+ thread: {
+ ...v2ThreadShell,
+ pullRequests: [link, { ...link, number: 8, stack: null, stackReference: 0 }],
+ },
+ });
+ const updated = yield* SubscriptionRef.changes(shellState).pipe(
+ Stream.filter((value) => Option.getOrNull(value.snapshot)?.snapshotSequence === 2),
+ Stream.runHead,
+ );
+ expect(
+ Option.getOrThrow(Option.getOrThrow(updated).snapshot).threads[0]?.pullRequests,
+ ).toEqual([link, { ...link, number: 8 }]);
}),
);
diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts
index 12e5f19e8997b..5dc4c9b358dba 100644
--- a/packages/client-runtime/src/state/shell.ts
+++ b/packages/client-runtime/src/state/shell.ts
@@ -29,7 +29,7 @@ import { runCachePersistence } from "./cachePersistence.ts";
import { subscribeDynamic } from "../rpc/client.ts";
import type { RpcSession } from "../rpc/session.ts";
import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts";
-import type { DeferredShellSnapshot } from "./shellPullRequests.ts";
+import { type DeferredShellSnapshot, restoreShellPullRequestStacks } from "./shellPullRequests.ts";
import {
applyShellStreamEvent,
mergeShellSnapshotProjects,
@@ -186,7 +186,22 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
let waiting = yield* Ref.get(awaitingCompletion);
let next = initial;
let receivedSnapshot = false;
- for (const item of items) {
+ for (const received of items) {
+ const item =
+ received.kind === "snapshot"
+ ? {
+ ...received,
+ snapshot: {
+ ...received.snapshot,
+ threads: received.snapshot.threads.map(restoreShellPullRequestStacks),
+ archivedThreads: received.snapshot.archivedThreads.map(
+ restoreShellPullRequestStacks,
+ ),
+ },
+ }
+ : received.kind === "thread.updated"
+ ? { ...received, thread: restoreShellPullRequestStacks(received.thread) }
+ : received;
if (item.kind === "synchronized") {
waiting = false;
if (Option.isSome(next.snapshot)) {
@@ -372,7 +387,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
}
if (!canResume || Option.isNone(current.snapshot)) {
- return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {};
+ return {
+ compactPullRequests: true,
+ ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
+ };
}
if (!supportsCompletionMarker) {
yield* SubscriptionRef.update(state, (value) => ({
@@ -383,6 +401,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
}
return {
afterSequence: current.snapshot.value.snapshotSequence,
+ compactPullRequests: true,
...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
};
}),
diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts
index 340b1403f2937..0c5ba50ecffcd 100644
--- a/packages/client-runtime/src/state/shellPullRequests.ts
+++ b/packages/client-runtime/src/state/shellPullRequests.ts
@@ -1,5 +1,7 @@
import {
OrchestrationV2ShellSnapshot,
+ OrchestrationV2ShellPullRequestLink,
+ type OrchestrationV2ThreadShell,
type ThreadId,
ThreadPullRequestLink,
} from "@t3tools/contracts";
@@ -18,8 +20,24 @@ export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot {
const yieldToHost = Effect.sleep("1 millis");
const decodeThreadLinks = Schema.decodeUnknownEffect(
- Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))),
+ Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(OrchestrationV2ShellPullRequestLink))),
);
+
+const restoreStackReferences = (links: ReadonlyArray) =>
+ links.some((link) => link.stackReference !== undefined)
+ ? links.map(({ stackReference, ...link }) => ({
+ ...link,
+ stack: stackReference === undefined ? link.stack : (links[stackReference]?.stack ?? null),
+ }))
+ : links;
+
+/** Restore before reducers, cached snapshots, badges or PR navigation use the links. */
+export const restoreShellPullRequestStacks = (thread: OrchestrationV2ThreadShell) => {
+ if (thread.pullRequests === undefined) return thread;
+ const links = restoreStackReferences(thread.pullRequests);
+ return links === thread.pullRequests ? thread : { ...thread, pullRequests: links };
+};
+// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches.
const LINKS_PER_BATCH = 400;
/**
@@ -64,7 +82,7 @@ export function deferPullRequests(
}).pipe(Effect.as(undefined)),
),
);
- if (links !== undefined) linksByThreadId.set(threadId, links);
+ if (links !== undefined) linksByThreadId.set(threadId, restoreStackReferences(links));
}
return linksByThreadId as ReadonlyMap>;
});
diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts
index ffbe173ef1bfd..bf79abf5536f0 100644
--- a/packages/client-runtime/src/state/shellReducer.test.ts
+++ b/packages/client-runtime/src/state/shellReducer.test.ts
@@ -148,6 +148,19 @@ describe("applyShellStreamEvent", () => {
expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
});
+ it("keeps the object of a project a full snapshot left unchanged", () => {
+ const prior = { ...v2Project, repositoryIdentity };
+ const next = mergeShellSnapshotProjects(
+ { ...v2ShellSnapshot, projects: [prior] },
+ {
+ ...v2ShellSnapshot,
+ snapshotSequence: 2,
+ projects: [{ ...v2Project, repositoryIdentity: structuredClone(repositoryIdentity) }],
+ },
+ );
+ expect(next.projects[0]).toBe(prior);
+ });
+
it("authoritative lower-sequence reset replaces client-ahead structural state", () => {
const previous = {
...v2ShellSnapshot,
diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts
index eec7f6c949fce..fb7b8c72d7d04 100644
--- a/packages/client-runtime/src/state/shellReducer.ts
+++ b/packages/client-runtime/src/state/shellReducer.ts
@@ -1,14 +1,15 @@
import {
+ OrchestrationProjectShell,
OrchestrationV2ThreadShell,
RepositoryIdentity,
ThreadPullRequestLink,
- type OrchestrationProjectShell,
type OrchestrationV2ShellSnapshot,
type OrchestrationV2ShellStreamItem,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell);
+const sameProjectShell = Schema.toEquivalence(OrchestrationProjectShell);
const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink)));
const sameRepositoryIdentity = Schema.toEquivalence(
Schema.UndefinedOr(Schema.NullOr(RepositoryIdentity)),
@@ -123,7 +124,9 @@ export function mergeShellSnapshotProjects(
if (resolvedRootSet?.has(project.workspaceRoot) === true) {
return project;
}
- return retainRepositoryIdentity(prior, project);
+ const merged = retainRepositoryIdentity(prior, project);
+ // Preserve identity across cached-to-live handoffs.
+ return prior !== undefined && sameProjectShell(prior, merged) ? prior : merged;
}),
};
}
diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts
index cdb5ca77db56f..278d21fa121f3 100644
--- a/packages/client-runtime/src/state/shellSnapshotHttp.ts
+++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts
@@ -38,12 +38,13 @@ export const fetchEnvironmentShellSnapshot = Effect.fn(
...input,
group: "orchestration",
method: "GET",
- url: (urls) => urls.shellSnapshot(),
+ url: (urls) => urls.shellSnapshot({ query: { compactPullRequests: "true" } }),
timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
request: ({ client, headers }) =>
client
.shellSnapshot({
headers: withOrchestrationProtocolHeader(headers),
+ query: { compactPullRequests: "true" },
responseMode: "response-only",
})
.pipe(Effect.flatMap(decodeShellSnapshotResponse)),
diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts
index 61c7f01c9b73c..026cece32851c 100644
--- a/packages/client-runtime/src/state/threads-sync.test.ts
+++ b/packages/client-runtime/src/state/threads-sync.test.ts
@@ -123,6 +123,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
const lastRequestCompletionMarker = yield* Ref.make(false);
const lastAcceptBoundedSnapshot = yield* Ref.make(undefined);
const lastAcceptCompactTurnItems = yield* Ref.make(undefined);
+ const lastAcceptCompactCheckpointItems = yield* Ref.make(undefined);
const wakeups = yield* Queue.unbounded();
const savedThreads = yield* Ref.make>([]);
const removedThreads = yield* Ref.make>([]);
@@ -141,6 +142,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
readonly requestCompletionMarker?: true;
readonly acceptBoundedSnapshot?: true;
readonly acceptCompactTurnItems?: true;
+ readonly acceptCompactCheckpointItems?: true;
}) =>
Stream.unwrap(
Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe(
@@ -150,6 +152,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
),
Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)),
Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)),
+ Effect.andThen(
+ Ref.set(lastAcceptCompactCheckpointItems, input.acceptCompactCheckpointItems),
+ ),
Effect.as(streamFrom(inputs)),
),
),
@@ -266,6 +271,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
lastRequestCompletionMarker,
lastAcceptBoundedSnapshot,
lastAcceptCompactTurnItems,
+ lastAcceptCompactCheckpointItems,
supervisorState,
supervisorSession,
savedThreads,
@@ -1039,6 +1045,7 @@ describe("EnvironmentThreads", () => {
expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]);
expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true);
expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true);
+ expect(yield* Ref.get(harness.lastAcceptCompactCheckpointItems)).toBe(true);
const updated = { ...local[1]!, output: "updated", ordinal: 2 };
yield* Queue.offer(harness.inputs, {
diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts
index dfeab0bc9f704..b34e847506e56 100644
--- a/packages/client-runtime/src/state/threads.ts
+++ b/packages/client-runtime/src/state/threads.ts
@@ -857,7 +857,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
...(canResume ? { afterSequence: sequence } : {}),
...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}),
...(acceptBoundedSnapshot
- ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const }
+ ? {
+ acceptBoundedSnapshot: true as const,
+ acceptCompactTurnItems: true as const,
+ acceptCompactCheckpointItems: true as const,
+ }
: {}),
};
}),
diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts
index 3a638e2b512d1..aa2961ae9a4b1 100644
--- a/packages/client-runtime/src/state/vcs.ts
+++ b/packages/client-runtime/src/state/vcs.ts
@@ -2,7 +2,6 @@ import {
type EnvironmentId,
type VcsListRefsInput,
type VcsListRefsResult,
- type VcsStatusResult,
WS_METHODS,
} from "@t3tools/contracts";
import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
@@ -11,6 +10,7 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as Schedule from "effect/Schedule";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { Atom, AtomRegistry } from "effect/reactivity";
@@ -280,16 +280,16 @@ export function createVcsEnvironmentAtoms(
status: createEnvironmentSubscriptionAtomFamily(runtime, {
label: "environment-data:vcs:status",
idleTtlMs: VCS_STATUS_IDLE_TTL_MS,
- subscribe: (input: EnvironmentRpcInput) =>
- subscribe(WS_METHODS.subscribeVcsStatus, input).pipe(
- Stream.mapAccum(
- () => null as VcsStatusResult | null,
- (current, event) => {
- const next = applyGitStatusStreamEvent(current, event);
- return [next, [next]] as const;
- },
- ),
- ),
+ subscribe: (input: EnvironmentRpcInput) => {
+ let current: ReturnType | null = null;
+ return subscribe(WS_METHODS.subscribeVcsStatus, { ...input, compact: true }).pipe(
+ Stream.filterMap((event) => {
+ if (event._tag === "unchanged") return Result.failVoid;
+ current = applyGitStatusStreamEvent(current, event);
+ return Result.succeed(current);
+ }),
+ );
+ },
}),
pull: createEnvironmentRpcCommand(runtime, {
label: "environment-data:vcs:pull",
diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts
index 31b69997f43cf..d1f0b0e98da2b 100644
--- a/packages/contracts/src/environmentHttp.ts
+++ b/packages/contracts/src/environmentHttp.ts
@@ -575,6 +575,7 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({
compactTurnItems: Schema.optionalKey(Schema.String),
+ compactCheckpointItems: Schema.optionalKey(Schema.String),
});
const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({
@@ -594,6 +595,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
.add(
HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {
headers: OrchestrationProtocolHeaders,
+ query: Schema.Struct({ compactPullRequests: Schema.optional(Schema.String) }),
success: OrchestrationV2ShellSnapshot,
error: EnvironmentOrchestrationSnapshotErrors,
}).middleware(EnvironmentAuthenticatedAuth),
diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts
index 382bad2f41033..431528dc96e99 100644
--- a/packages/contracts/src/git.ts
+++ b/packages/contracts/src/git.ts
@@ -112,6 +112,9 @@ export const VcsStatusSubscriptionInput = Schema.Struct({
...VcsStatusInput.fields,
/** Passive observers receive cached remote status without retaining its refresh loop. */
includeRemote: Schema.optional(Schema.Boolean),
+ /** Opt-in file-free summaries. Older servers ignore these fields. */
+ compact: Schema.optionalKey(Schema.Boolean),
+ includeFiles: Schema.optionalKey(Schema.Boolean),
});
export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type;
@@ -218,6 +221,8 @@ const VcsStatusLocalShape = {
refName: Schema.NullOr(TrimmedNonEmptyStringSchema),
hasWorkingTreeChanges: Schema.Boolean,
workingTree: Schema.Struct({
+ /** Present on summaries, whose file list is intentionally omitted. */
+ fileCount: Schema.optionalKey(NonNegativeInt),
files: Schema.Array(
Schema.Struct({
path: GitFilePath,
@@ -261,7 +266,9 @@ export const VcsStatusStreamEvent = Schema.Union([
Schema.TaggedStruct("snapshot", {
local: VcsStatusLocalResult,
remote: Schema.NullOr(VcsStatusRemoteResult),
+ version: Schema.optionalKey(TrimmedNonEmptyStringSchema),
}),
+ Schema.TaggedStruct("unchanged", { version: TrimmedNonEmptyStringSchema }),
Schema.TaggedStruct("localUpdated", {
local: VcsStatusLocalResult,
}),
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 65be14b9724c1..bc0b960a5e80f 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -1817,6 +1817,13 @@ export const OrchestrationV2LatestVisibleMessageSummary = Schema.Struct({
export type OrchestrationV2LatestVisibleMessageSummary =
typeof OrchestrationV2LatestVisibleMessageSummary.Type;
+/** Opted-in shells reference an earlier link's identical native stack. */
+export const OrchestrationV2ShellPullRequestLink = Schema.Struct({
+ ...ThreadPullRequestLink.fields,
+ stackReference: Schema.optionalKey(NonNegativeInt),
+});
+export type OrchestrationV2ShellPullRequestLink = typeof OrchestrationV2ShellPullRequestLink.Type;
+
export const OrchestrationV2ThreadShell = Schema.Struct({
...OrchestrationV2CreationFields,
id: ThreadId,
@@ -1829,7 +1836,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
branch: Schema.NullOr(TrimmedNonEmptyString),
worktreePath: Schema.NullOr(TrimmedNonEmptyString),
linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
- pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)),
+ pullRequests: Schema.optional(Schema.Array(OrchestrationV2ShellPullRequestLink)),
+ /** Pull request discovered from the thread's current branch. */
branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),
lineage: OrchestrationV2AppThreadLineage,
forkedFrom: Schema.NullOr(OrchestrationV2AppThread.fields.forkedFrom),
@@ -3216,6 +3224,8 @@ export type OrchestrationV2GetThreadProjectionInput =
export const OrchestrationV2SubscribeShellInput = Schema.Struct({
afterSequence: Schema.optionalKey(NonNegativeInt),
requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
+ /** PR views load full snapshots separately; shells need badge and chain summaries. */
+ compactPullRequests: Schema.optionalKey(Schema.Boolean),
});
export type OrchestrationV2SubscribeShellInput = typeof OrchestrationV2SubscribeShellInput.Type;
@@ -3225,6 +3235,11 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({
requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean),
acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean),
+ /**
+ * Allows bounded snapshot fallbacks to send checkpoint turn items without the
+ * file list their checkpoint already carries. See `checkpointFilesOmittedItemIds`.
+ */
+ acceptCompactCheckpointItems: Schema.optionalKey(Schema.Boolean),
/** Accepts compact read-watermark and pull-request-link events. */
acceptThreadFieldEvents: Schema.optionalKey(Schema.Boolean),
});
@@ -3260,6 +3275,13 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({
* with `boundedSnapshotProjection` before using the projection.
*/
turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)),
+ /**
+ * Set only for clients that opted in: these checkpoint turn items are sent
+ * with empty `files`, which equal their checkpoint's `files` in
+ * `projection.checkpoints`. Clients must restore them with
+ * `boundedSnapshotProjection` before using the projection.
+ */
+ checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)),
});
export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type;
@@ -3339,6 +3361,8 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([
latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)),
payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean),
turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)),
+ /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */
+ checkpointFilesOmittedItemIds: Schema.optionalKey(Schema.Array(TurnItemId)),
}),
Schema.Struct({
kind: Schema.Literal("event"),
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index b74175d23c6f6..4f3ca0ae56331 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -762,7 +762,7 @@ const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {
});
const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {
- payload: Schema.Struct({}),
+ payload: Schema.Struct({ refresh: Schema.optionalKey(Schema.Boolean) }),
success: SourceControlDiscoveryResult,
error: EnvironmentAuthorizationError,
});
@@ -1744,6 +1744,9 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon
* client would send it to the provider as an ordinary prompt.
*/
usageLimitsCommand: Schema.optional(Schema.Boolean),
+ /** Opt-in config deltas and conditional reconnect snapshots. */
+ configDeltas: Schema.optionalKey(Schema.Boolean),
+ knownConfigVersion: Schema.optionalKey(TrimmedNonEmptyString),
}),
success: ServerConfigStreamEvent,
error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 62258a03b6ce2..313a2caf68f1a 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -1,5 +1,6 @@
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
+import * as Struct from "effect/Struct";
import { AcpRegistryUrlAuthAction } from "./acpRegistry.ts";
import {
type EnvironmentMachineKind,
@@ -689,6 +690,7 @@ export const ServerConfigStreamSnapshotEvent = Schema.Struct({
version: Schema.Literal(1),
type: Schema.Literal("snapshot"),
config: ServerConfig,
+ configVersion: Schema.optionalKey(TrimmedNonEmptyString),
});
export type ServerConfigStreamSnapshotEvent = typeof ServerConfigStreamSnapshotEvent.Type;
@@ -751,6 +753,33 @@ export const ServerConfigStreamEvent = Schema.Union([
ServerConfigStreamSettingsUpdatedEvent,
ServerConfigStreamEnvironmentThemesUpdatedEvent,
ServerConfigStreamUsageLimitSourcesUpdatedEvent,
+ Schema.Struct({
+ version: Schema.Literal(1),
+ type: Schema.Literal("configUnchanged"),
+ configVersion: TrimmedNonEmptyString,
+ }),
+ Schema.Struct({
+ version: Schema.Literal(1),
+ type: Schema.Literal("configDelta"),
+ configVersion: TrimmedNonEmptyString,
+ changes: ServerConfig.mapFields(Struct.map(Schema.optionalKey)),
+ removed: Schema.Array(Schema.String),
+ providerChanges: Schema.Array(
+ Schema.Struct({
+ instanceId: ProviderInstanceId,
+ // Omitted fields must not clear values. Drop unreadable providers as snapshots do.
+ changes: Schema.UndefinedOr(
+ Schema.Struct({
+ ...ServerProvider.mapFields(Struct.map(Schema.optionalKey)).fields,
+ slashCommands: Schema.optionalKey(Schema.Array(ServerProviderSlashCommand)),
+ skills: Schema.optionalKey(Schema.Array(ServerProviderSkill)),
+ }),
+ ).pipe(Schema.catchDecoding(() => Effect.succeedSome(undefined))),
+ removed: Schema.Array(Schema.String),
+ }),
+ ),
+ providerOrder: Schema.optionalKey(Schema.Array(ProviderInstanceId)),
+ }),
]);
export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;
diff --git a/packages/provider-acp-registry/package.json b/packages/provider-acp-registry/package.json
index cd192ba0f1f97..3e48dec325d84 100644
--- a/packages/provider-acp-registry/package.json
+++ b/packages/provider-acp-registry/package.json
@@ -26,6 +26,10 @@
"./server/AcpRegistryRuntimeCoordinator": {
"types": "./src/server/AcpRegistryRuntimeCoordinator.ts",
"import": "./src/server/AcpRegistryRuntimeCoordinator.ts"
+ },
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
}
},
"scripts": {
diff --git a/packages/provider-acp-registry/src/server.ts b/packages/provider-acp-registry/src/server.ts
index 636fe07230ada..29516b2fb4ea5 100644
--- a/packages/provider-acp-registry/src/server.ts
+++ b/packages/provider-acp-registry/src/server.ts
@@ -1,6 +1,4 @@
-export { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./server/driver.ts";
export {
AcpRegistryAdapterV2Driver,
type AcpRegistryAdapterV2DriverEnv,
} from "./server/adapter.ts";
-export { acpRegistryManagedBinaryDirectories } from "./server/AcpRegistrySupport.ts";
diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts
index dce555f0c5dc3..330f518cdb023 100644
--- a/packages/provider-acp-registry/src/server/driver.ts
+++ b/packages/provider-acp-registry/src/server/driver.ts
@@ -1,3 +1,4 @@
+import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter";
import {
officialAcpRegistryIconUrlForAgentId,
ProviderDriverKind,
@@ -22,7 +23,7 @@ import * as Semaphore from "effect/Semaphore";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
-import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts";
+import type { AcpRegistryAdapterV2DriverEnv } from "./adapter.ts";
import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
@@ -498,22 +499,30 @@ export const AcpRegistryDriver: ProviderDriver
- new ProviderDriverError({
- driver: DRIVER_KIND,
+ DRIVER_KIND,
+ Effect.promise(() => import("./adapter.ts")).pipe(
+ Effect.flatMap(({ AcpRegistryAdapterV2Driver }) =>
+ AcpRegistryAdapterV2Driver.create({
instanceId,
- detail: "Failed to build ACP Registry orchestration adapter.",
- cause,
- }),
+ displayName,
+ accentColor,
+ environment,
+ enabled,
+ config,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build ACP Registry orchestration adapter.",
+ cause,
+ }),
+ ),
+ ),
+ ),
),
);
const readinessInput = {
diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json
index 7003e0a82c9b8..7b2bcf34a3fb9 100644
--- a/packages/provider-core/package.json
+++ b/packages/provider-core/package.json
@@ -182,6 +182,10 @@
"./server/usageLimits": {
"types": "./src/server/usageLimits.ts",
"import": "./src/server/usageLimits.ts"
+ },
+ "./server/lazyAdapter": {
+ "types": "./src/server/lazyAdapter.ts",
+ "import": "./src/server/lazyAdapter.ts"
}
},
"scripts": {
diff --git a/packages/provider-core/src/server/lazyAdapter.ts b/packages/provider-core/src/server/lazyAdapter.ts
new file mode 100644
index 0000000000000..253cfdf213f2a
--- /dev/null
+++ b/packages/provider-core/src/server/lazyAdapter.ts
@@ -0,0 +1,63 @@
+import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import { cacheInOwnerScope } from "@t3tools/shared/lazyService";
+import * as Effect from "effect/Effect";
+import { ProviderAdapterDriverCreateError } from "./adapterDriver.ts";
+import * as ProviderAdapter from "./ProviderAdapter.ts";
+
+/**
+ * Keep construction in the instance scope while sharing it across concurrent
+ * first uses. A failed construction, including a failed import of the
+ * adapter's module, surfaces as the method's typed error and is retried on
+ * the next use.
+ */
+export const makeLazyAdapter = (
+ instanceId: ProviderInstanceId,
+ driver: ProviderDriverKind,
+ make: Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const adapter = yield* cacheInOwnerScope(
+ make.pipe(
+ Effect.catchDefect((cause) =>
+ Effect.fail(
+ new ProviderAdapterDriverCreateError({
+ driver,
+ instanceId,
+ detail: "The adapter could not load.",
+ cause,
+ }),
+ ),
+ ),
+ ),
+ );
+ return ProviderAdapter.ProviderAdapterV2.of({
+ instanceId,
+ driver,
+ getCapabilities: () =>
+ adapter.pipe(
+ Effect.mapError(
+ (cause) => new ProviderAdapter.ProviderAdapterCapabilitiesError({ driver, cause }),
+ ),
+ Effect.flatMap((value) => value.getCapabilities()),
+ ),
+ planSelectionTransition: (input) =>
+ adapter.pipe(
+ Effect.mapError(
+ (cause) => new ProviderAdapter.ProviderAdapterCapabilitiesError({ driver, cause }),
+ ),
+ Effect.flatMap((value) => value.planSelectionTransition(input)),
+ ),
+ openSession: (input) =>
+ adapter.pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderAdapter.ProviderAdapterOpenSessionError({
+ driver,
+ providerSessionId: input.providerSessionId,
+ cause,
+ }),
+ ),
+ Effect.flatMap((value) => value.openSession(input)),
+ ),
+ });
+ });
diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts
index 9d2fa03d50601..4df9c11c25fdc 100644
--- a/packages/provider-core/src/server/managedProvider.test.ts
+++ b/packages/provider-core/src/server/managedProvider.test.ts
@@ -1,3 +1,8 @@
+import * as Exit from "effect/Exit";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as ProviderAdapter from "./ProviderAdapter.ts";
+import { ProviderAdapterDriverCreateError } from "./adapterDriver.ts";
+import { makeLazyAdapter } from "./lazyAdapter.ts";
import { describe, it, assert } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
@@ -13,6 +18,7 @@ import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
@@ -715,3 +721,108 @@ describe("makeManagedServerProvider", () => {
).pipe(Effect.provide(layerAlwaysRunTest)),
);
});
+
+const lazyProbeAdapter = ProviderAdapter.ProviderAdapterV2.of({
+ instanceId: ProviderInstanceId.make("lazy"),
+ driver: ProviderDriverKind.make("codex"),
+ getCapabilities: () =>
+ Effect.fail(
+ new ProviderAdapter.ProviderAdapterCapabilitiesError({
+ driver: ProviderDriverKind.make("codex"),
+ cause: "from the adapter",
+ }),
+ ),
+ planSelectionTransition: () => Effect.die("unused"),
+ openSession: () => Effect.die("unused"),
+});
+
+it.effect(
+ "constructs a lazy adapter once in its instance scope across concurrent request scopes",
+ () =>
+ Effect.gen(function* () {
+ let constructed = 0;
+ let finalized = 0;
+ let owner: string | undefined;
+ const started = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const driver = ProviderDriverKind.make("codex");
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ const adapter = yield* makeLazyAdapter(
+ ProviderInstanceId.make("lazy"),
+ driver,
+ Effect.gen(function* () {
+ constructed++;
+ owner = (yield* HostProcess.Environment).OWNER;
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ finalized++;
+ }),
+ );
+ yield* Deferred.succeed(started, undefined);
+ yield* Deferred.await(release);
+ return lazyProbeAdapter;
+ }),
+ );
+ assert.equal(constructed, 0);
+ const abandoned = yield* adapter.getCapabilities().pipe(Effect.forkChild);
+ yield* Deferred.await(started);
+ yield* Fiber.interrupt(abandoned);
+ const requests = yield* Effect.all(
+ [
+ adapter.getCapabilities().pipe(Effect.scoped, Effect.exit),
+ adapter.getCapabilities().pipe(Effect.scoped, Effect.exit),
+ ],
+ { concurrency: "unbounded" },
+ ).pipe(
+ Effect.provideService(HostProcess.Environment, { OWNER: "request" }),
+ Effect.forkScoped,
+ );
+ assert.equal(constructed, 1);
+ yield* Deferred.succeed(release, undefined);
+ const exits = yield* Fiber.join(requests);
+ assert.isTrue(exits.every(Exit.isFailure));
+ assert.equal(owner, "instance");
+ assert.equal(finalized, 0);
+ yield* adapter.getCapabilities().pipe(Effect.scoped, Effect.exit);
+ assert.equal(constructed, 1);
+ assert.equal(finalized, 0);
+ }).pipe(Effect.provideService(HostProcess.Environment, { OWNER: "instance" })),
+ );
+ assert.equal(finalized, 1);
+ }),
+);
+
+const isDriverCreateError = Schema.is(ProviderAdapterDriverCreateError);
+
+it.effect("retries a lazy adapter whose construction or module import failed", () =>
+ Effect.gen(function* () {
+ let attempts = 0;
+ const driver = ProviderDriverKind.make("codex");
+ const adapter = yield* makeLazyAdapter(
+ ProviderInstanceId.make("lazy"),
+ driver,
+ Effect.suspend(() => {
+ attempts++;
+ // An upgrade replaced the adapter's chunk under the running server.
+ if (attempts === 1) return Effect.die(new Error("Cannot find module 'adapter-old.mjs'"));
+ if (attempts === 2) return Effect.fail("shadow home unavailable");
+ return Effect.succeed(lazyProbeAdapter);
+ }),
+ );
+ const missingModule = yield* adapter.getCapabilities().pipe(Effect.flip);
+ assert.strictEqual(missingModule._tag, "ProviderAdapterCapabilitiesError");
+ const loadError = missingModule.cause;
+ if (!isDriverCreateError(loadError)) {
+ return assert.fail("Expected the failed import as a ProviderAdapterDriverCreateError");
+ }
+ assert.include(String(loadError.cause), "Cannot find module");
+ const failed = yield* adapter.getCapabilities().pipe(Effect.flip);
+ assert.strictEqual(failed.cause, "shadow home unavailable");
+ for (let call = 0; call < 2; call++) {
+ const built = yield* adapter.getCapabilities().pipe(Effect.flip);
+ assert.strictEqual(built.cause, "from the adapter");
+ }
+ assert.equal(attempts, 3);
+ }).pipe(Effect.scoped),
+);
diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json
index 1b4aa9f332839..000849d1141af 100644
--- a/packages/provider-cursor/package.json
+++ b/packages/provider-cursor/package.json
@@ -42,6 +42,18 @@
"./server/CursorSdk": {
"types": "./src/server/CursorSdk.ts",
"import": "./src/server/CursorSdk.ts"
+ },
+ "./server/sdk": {
+ "types": "./src/server/sdk.ts",
+ "import": "./src/server/sdk.ts"
+ },
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
+ },
+ "./server/keychainToken": {
+ "types": "./src/server/keychainToken.ts",
+ "import": "./src/server/keychainToken.ts"
}
},
"scripts": {
diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
index d82caca0c96c1..ff974ccf0a238 100644
--- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
+++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
@@ -94,17 +94,19 @@ const cursorSdkMock = vi.hoisted(() => {
});
vi.mock("./sdk.ts", () => ({
- Agent: {
- create: cursorSdkMock.create,
- resume: cursorSdkMock.resume,
- listRuns: cursorSdkMock.listRuns,
- cancelRun: cursorSdkMock.cancelRun,
- messages: {
- list: vi.fn(async () => []),
+ loadCursorSdk: () => ({
+ Agent: {
+ create: cursorSdkMock.create,
+ resume: cursorSdkMock.resume,
+ listRuns: cursorSdkMock.listRuns,
+ cancelRun: cursorSdkMock.cancelRun,
+ messages: {
+ list: vi.fn(async () => []),
+ },
},
- },
- createAgentPlatform: vi.fn(),
- SqliteLocalAgentStore: { open: vi.fn() },
+ createAgentPlatform: vi.fn(),
+ }),
+ loadCursorSqlite: () => ({ SqliteLocalAgentStore: { open: vi.fn() } }),
}));
const layerTest = CursorAgentSdk.layer.pipe(
diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts
index 48c85af25487f..c85de7bacf4bd 100644
--- a/packages/provider-cursor/src/server/CursorAgentSdk.ts
+++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts
@@ -328,7 +328,7 @@ function makeCursorAgentSdkProtocolLogger(input: {
export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* (
protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined,
) {
- const { Agent, createAgentPlatform } = yield* CursorSdk.CursorSdk;
+ const sdk = yield* CursorSdk.CursorSdk;
/**
* The Cursor SDK decides once per process whether local sandboxing works, and
@@ -346,7 +346,9 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu
}
yield* Effect.tryPromise(async () => {
const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined;
- const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd });
+ const platform = await sdk.createAgentPlatform(
+ cwd === undefined ? {} : { workspaceRef: cwd },
+ );
const release = await platform.prewarmLocalWorkspace({
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
local: {
@@ -362,6 +364,7 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu
return CursorAgentSdkRunner.of({
open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) {
+ const { Agent } = sdk;
if (input.options.local?.sandboxOptions?.enabled === false) {
yield* primeCursorSandboxSupport(input.options);
}
diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts
index 5de35e05eef6a..5e62e4c87935f 100644
--- a/packages/provider-cursor/src/server/CursorSdk.ts
+++ b/packages/provider-cursor/src/server/CursorSdk.ts
@@ -3,7 +3,9 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts";
+import { loadCursorSdk, loadCursorSqlite } from "./sdk.ts";
+import type { Agent, createAgentPlatform } from "@cursor/sdk";
+import type { SqliteLocalAgentStore } from "@cursor/sdk/sqlite";
/**
* The SDK listens for that error, then rejects an internal promise nothing awaits.
@@ -70,5 +72,19 @@ export const layer = Layer.effect(
Effect.sync(() => {
process.off("unhandledRejection", onUnhandledRejection);
}),
- ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform, SqliteLocalAgentStore }))),
+ ).pipe(
+ Effect.as(
+ CursorSdk.of({
+ get Agent() {
+ return loadCursorSdk().Agent;
+ },
+ get createAgentPlatform() {
+ return loadCursorSdk().createAgentPlatform;
+ },
+ get SqliteLocalAgentStore() {
+ return loadCursorSqlite().SqliteLocalAgentStore;
+ },
+ }),
+ ),
+ ),
);
diff --git a/packages/provider-cursor/src/server/CursorSdkCatalog.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.ts
index 7f317cc0978fd..8d4df46d35d06 100644
--- a/packages/provider-cursor/src/server/CursorSdkCatalog.ts
+++ b/packages/provider-cursor/src/server/CursorSdkCatalog.ts
@@ -7,7 +7,7 @@ import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
-import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts";
+import { loadCursorSdk } from "./sdk.ts";
export interface CursorSdkCatalogSnapshot {
readonly user: SDKUser;
@@ -38,6 +38,7 @@ export class CursorSdkCatalog extends Context.Service<
>()("@t3tools/provider-cursor/server/CursorSdkCatalog") {}
function isAuthenticationFailure(cause: unknown): boolean {
+ const { AuthenticationError, CursorSdkError } = loadCursorSdk();
return (
cause instanceof AuthenticationError ||
(cause instanceof CursorSdkError && cause.status === 401)
@@ -62,8 +63,8 @@ const sdkRequest = (tryRequest: () => Promise) =>
});
const liveProbes: CursorSdkCatalogProbes = {
- readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })),
- readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })),
+ readUser: (apiKey) => sdkRequest(() => loadCursorSdk().Cursor.me({ apiKey })),
+ readModels: (apiKey) => sdkRequest(() => loadCursorSdk().Cursor.models.list({ apiKey })),
};
export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* (
diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts
index be87e4a481968..c080c5f792d78 100644
--- a/packages/provider-cursor/src/server/adapter.test.ts
+++ b/packages/provider-cursor/src/server/adapter.test.ts
@@ -36,7 +36,7 @@ import {
nestedToolCallFromEnvelope,
} from "./adapter.ts";
import * as CursorAgentSdk from "./CursorAgentSdk.ts";
-import { ConfigurationError } from "./sdk.ts";
+import { loadCursorSdk } from "./sdk.ts";
const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
@@ -940,7 +940,7 @@ describe("CursorAdapterV2", () => {
: Effect.fail(
new CursorAgentSdk.CursorAgentSdkRunnerError({
method,
- cause: new ConfigurationError(
+ cause: new (loadCursorSdk().ConfigurationError)(
`Cannot use this model: ${model.id}. Available models: default, composer-2.5. Use Cursor.models.list() to discover valid selections.`,
{ isRetryable: false },
),
diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts
index ae495016e9ff9..e81f38baf227a 100644
--- a/packages/provider-cursor/src/server/adapter.ts
+++ b/packages/provider-cursor/src/server/adapter.ts
@@ -74,7 +74,7 @@ import {
subagentThreadTitle,
} from "@t3tools/provider-core/server/subagentProjection";
import * as CursorAgentSdk from "./CursorAgentSdk.ts";
-import { ConfigurationError } from "./sdk.ts";
+import { loadCursorSdk } from "./sdk.ts";
export { cursorSdkModelSelection } from "./sdkModel.ts";
export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER;
@@ -351,7 +351,7 @@ class CursorModelUnavailableError extends Schema.TaggedError (error: CursorAgentSdk.CursorAgentSdkRunnerError) =>
- error.cause instanceof ConfigurationError &&
+ error.cause instanceof loadCursorSdk().ConfigurationError &&
error.cause.message.startsWith("Cannot use this model:")
? new CursorModelUnavailableError({ model: modelSelection.model, cause: error.cause })
: error;
diff --git a/packages/provider-cursor/src/server/auth.test.ts b/packages/provider-cursor/src/server/auth.test.ts
index 3d31211ac5aef..0974f26bbbb90 100644
--- a/packages/provider-cursor/src/server/auth.test.ts
+++ b/packages/provider-cursor/src/server/auth.test.ts
@@ -13,7 +13,8 @@ import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts";
-import { InMemoryCredentialStore } from "./sdk.ts";
+import { loadCursorSdk } from "./sdk.ts";
+const { InMemoryCredentialStore } = loadCursorSdk();
const instanceId = ProviderInstanceId.make("cursor-personal");
const owner = "owner-client";
diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts
index 198bbeb37279d..730f38147c6b1 100644
--- a/packages/provider-cursor/src/server/auth.ts
+++ b/packages/provider-cursor/src/server/auth.ts
@@ -16,7 +16,6 @@ import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
-import { Cursor, InMemoryCredentialStore } from "./sdk.ts";
import type { ProviderAuthController } from "@t3tools/provider-core/server/auth";
const AUTH_TIMEOUT_MS = 300_000;
@@ -158,6 +157,9 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu
Effect.gen(function* () {
yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials));
// The SDK first writes into memory. Cancellation must not save a late login result.
+ const { Cursor, InMemoryCredentialStore } = yield* Effect.promise(
+ () => import("./sdk.ts"),
+ ).pipe(Effect.map(({ loadCursorSdk }) => loadCursorSdk()));
const pendingStore = new InMemoryCredentialStore();
const urls = yield* Queue.unbounded();
yield* Queue.take(urls).pipe(
diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts
index 8215f6563d488..2c2fd49183d78 100644
--- a/packages/provider-cursor/src/server/driver.test.ts
+++ b/packages/provider-cursor/src/server/driver.test.ts
@@ -19,7 +19,8 @@ import * as CursorKeychain from "./CursorKeychain.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
-import { Cursor } from "./sdk.ts";
+import { loadCursorSdk } from "./sdk.ts";
+const { Cursor } = loadCursorSdk();
const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
Layer.provideMerge(NodeServices.layer),
diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts
index e938024ffc58a..78b52b4b91040 100644
--- a/packages/provider-cursor/src/server/driver.ts
+++ b/packages/provider-cursor/src/server/driver.ts
@@ -1,3 +1,4 @@
+import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter";
import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
import { CursorSettings } from "../settings.ts";
import * as Effect from "effect/Effect";
@@ -10,7 +11,7 @@ import { readCursorUsageLimits } from "./usageLimits.ts";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import { makeCursorTextGeneration } from "./textGeneration.ts";
-import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts";
+import type { CursorAdapterV2DriverEnv } from "./adapter.ts";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts";
import * as CursorSdkCatalog from "./CursorSdkCatalog.ts";
@@ -154,48 +155,56 @@ export const CursorDriver: ProviderDriver<
},
});
- const orchestrationAdapter = yield* CursorAdapterV2Driver.create({
+ const orchestrationAdapter = yield* makeLazyAdapter(
instanceId,
- displayName,
- accentColor,
- environment,
- enabled,
- config,
- }).pipe(
- Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
- ...sdkRunner,
- open: (input) =>
- auth.requireApiKey.pipe(
- Effect.flatMap((apiKey) =>
- Effect.acquireRelease(
- sdkRunner
- .open({ ...input, options: { ...input.options, apiKey } })
- .pipe(
- Effect.flatMap((session) =>
- Effect.cached(session.close).pipe(
- Effect.map((close) => ({ ...session, close })),
- ),
+ DRIVER_KIND,
+ Effect.promise(() => import("./adapter.ts")).pipe(
+ Effect.flatMap(({ CursorAdapterV2Driver }) =>
+ CursorAdapterV2Driver.create({
+ instanceId,
+ displayName,
+ accentColor,
+ environment,
+ enabled,
+ config,
+ }).pipe(
+ Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
+ ...sdkRunner,
+ open: (input) =>
+ auth.requireApiKey.pipe(
+ Effect.flatMap((apiKey) =>
+ Effect.acquireRelease(
+ sdkRunner
+ .open({ ...input, options: { ...input.options, apiKey } })
+ .pipe(
+ Effect.flatMap((session) =>
+ Effect.cached(session.close).pipe(
+ Effect.map((close) => ({ ...session, close })),
+ ),
+ ),
+ ),
+ (session) => session.close.pipe(Effect.ignore),
),
),
- (session) => session.close.pipe(Effect.ignore),
- ),
- ),
- auth.withAccess,
- Effect.mapError((cause) =>
- isSdkRunnerError(cause)
- ? cause
- : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }),
+ auth.withAccess,
+ Effect.mapError((cause) =>
+ isSdkRunnerError(cause)
+ ? cause
+ : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }),
+ ),
+ ),
+ }),
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build Cursor orchestration adapter.",
+ cause,
+ }),
),
),
- }),
- Effect.mapError(
- (cause) =>
- new ProviderDriverError({
- driver: DRIVER_KIND,
- instanceId,
- detail: "Failed to build Cursor orchestration adapter.",
- cause,
- }),
+ ),
),
);
const textGeneration = yield* makeCursorTextGeneration(
diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts
index 431f3cf8cd86f..cb596c82cf68b 100644
--- a/packages/provider-cursor/src/server/sdk.ts
+++ b/packages/provider-cursor/src/server/sdk.ts
@@ -4,15 +4,9 @@ import * as NodeModule from "node:module";
// Cursor's Webpack chunks and local helpers must stay beside the SDK entry.
// createRequire also loads that disk-backed package from a Node SEA executable.
const requireCursorSdk = NodeModule.createRequire(import.meta.url);
-export const {
- Agent,
- AuthenticationError,
- ConfigurationError,
- createAgentPlatform,
- Cursor,
- CursorSdkError,
- InMemoryCredentialStore,
-} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk");
-export const { SqliteLocalAgentStore } = requireCursorSdk(
- "@cursor/sdk/sqlite",
-) as typeof import("@cursor/sdk/sqlite");
+let sdk: typeof import("@cursor/sdk") | undefined;
+export const loadCursorSdk = () =>
+ (sdk ??= requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"));
+let sqlite: typeof import("@cursor/sdk/sqlite") | undefined;
+export const loadCursorSqlite = () =>
+ (sqlite ??= requireCursorSdk("@cursor/sdk/sqlite") as typeof import("@cursor/sdk/sqlite"));
diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts
index 59fcfe67ed659..d868c0e19f98c 100644
--- a/packages/provider-cursor/src/server/textGeneration.test.ts
+++ b/packages/provider-cursor/src/server/textGeneration.test.ts
@@ -33,9 +33,11 @@ const cursorSdkMock = vi.hoisted(() => ({
}));
vi.mock("./sdk.ts", () => ({
- Agent: { create: cursorSdkMock.create },
- createAgentPlatform: vi.fn(),
- SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen },
+ loadCursorSdk: () => ({
+ Agent: { create: cursorSdkMock.create },
+ createAgentPlatform: vi.fn(),
+ }),
+ loadCursorSqlite: () => ({ SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen } }),
}));
let hasCustomPolicy = false;
diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts
index 1797e5d38522d..a1205a7f1fd37 100644
--- a/packages/provider-cursor/src/server/textGeneration.ts
+++ b/packages/provider-cursor/src/server/textGeneration.ts
@@ -54,7 +54,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
withAccess?: CursorAuth["withAccess"],
) {
const fs = yield* FileSystem.FileSystem;
- const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk;
+ const sdk = yield* CursorSdk.CursorSdk;
const resolvedEnvironment = environment ?? process.env;
const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) =>
@@ -98,7 +98,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
// agent's workspace stays empty.
const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-state-" });
const store = yield* Effect.acquireRelease(
- Effect.tryPromise(() => SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot })),
+ Effect.tryPromise(() => sdk.SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot })),
(store) => Effect.tryPromise(() => store.dispose()).pipe(Effect.ignore({ log: true })),
);
const agentOptions = {
@@ -116,7 +116,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
} satisfies AgentOptions;
const createCursorAgent = (sandboxEnabled: boolean) =>
Effect.tryPromise((signal) =>
- Agent.create(
+ sdk.Agent.create(
sandboxEnabled
? agentOptions
: {
diff --git a/packages/provider-grok/package.json b/packages/provider-grok/package.json
index 1187be63a0344..5312186db181b 100644
--- a/packages/provider-grok/package.json
+++ b/packages/provider-grok/package.json
@@ -22,6 +22,10 @@
"./testing": {
"types": "./src/testing.ts",
"import": "./src/testing.ts"
+ },
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
}
},
"scripts": {
diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts
index 9be2dd85b9e91..a15de5ef64e19 100644
--- a/packages/provider-grok/src/server/driver.ts
+++ b/packages/provider-grok/src/server/driver.ts
@@ -1,3 +1,4 @@
+import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter";
import { ProviderDriverKind } from "@t3tools/contracts";
import { GrokSettings } from "../settings.ts";
import * as Crypto from "effect/Crypto";
@@ -12,7 +13,7 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import { makeGrokTextGeneration } from "./textGeneration.ts";
-import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./adapter.ts";
+import type { GrokAdapterV2DriverEnv } from "./adapter.ts";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
import {
buildInitialGrokProviderSnapshot,
@@ -124,22 +125,30 @@ export const GrokDriver: ProviderDriver
Effect.provideService(Path.Path, path),
),
);
- const orchestrationAdapter = yield* GrokAdapterV2Driver.create({
+ const orchestrationAdapter = yield* makeLazyAdapter(
instanceId,
- displayName,
- accentColor,
- environment,
- enabled,
- config,
- }).pipe(
- Effect.mapError(
- (cause) =>
- new ProviderDriverError({
- driver: DRIVER_KIND,
+ DRIVER_KIND,
+ Effect.promise(() => import("./adapter.ts")).pipe(
+ Effect.flatMap(({ GrokAdapterV2Driver }) =>
+ GrokAdapterV2Driver.create({
instanceId,
- detail: "Failed to build Grok orchestration adapter.",
- cause,
- }),
+ displayName,
+ accentColor,
+ environment,
+ enabled,
+ config,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build Grok orchestration adapter.",
+ cause,
+ }),
+ ),
+ ),
+ ),
),
);
const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
diff --git a/packages/provider-muse/package.json b/packages/provider-muse/package.json
index a54d78fc585bc..692cd271e301c 100644
--- a/packages/provider-muse/package.json
+++ b/packages/provider-muse/package.json
@@ -18,6 +18,10 @@
"./testing": {
"types": "./src/testing.ts",
"import": "./src/testing.ts"
+ },
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
}
},
"scripts": {
diff --git a/packages/provider-muse/src/server.ts b/packages/provider-muse/src/server.ts
index eab3a275e5988..f551f18971913 100644
--- a/packages/provider-muse/src/server.ts
+++ b/packages/provider-muse/src/server.ts
@@ -1,2 +1 @@
-export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts";
export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts";
diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts
index 9a70f74eed835..7f0f86830d2bd 100644
--- a/packages/provider-muse/src/server/driver.ts
+++ b/packages/provider-muse/src/server/driver.ts
@@ -1,3 +1,4 @@
+import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter";
import { ProviderDriverKind } from "@t3tools/contracts";
import { MuseSettings } from "../settings.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
@@ -14,7 +15,6 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { makeMuseTextGeneration } from "./textGeneration.ts";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
-import { makeMuseAdapterV2 } from "./adapter.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts";
@@ -157,14 +157,22 @@ export const MuseDriver: ProviderDriver = {
),
);
const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models));
- const orchestrationAdapter = yield* makeMuseAdapterV2({
+ const orchestrationAdapter = yield* makeLazyAdapter(
instanceId,
- settings: effectiveConfig,
- environment: processEnvironment,
- modelCatalog,
- ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
- continuationRequests,
- });
+ DRIVER_KIND,
+ Effect.promise(() => import("./adapter.ts")).pipe(
+ Effect.flatMap(({ makeMuseAdapterV2 }) =>
+ makeMuseAdapterV2({
+ instanceId,
+ settings: effectiveConfig,
+ environment: processEnvironment,
+ modelCatalog,
+ ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
+ continuationRequests,
+ }),
+ ),
+ ),
+ );
const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, {
environment: processEnvironment,
modelCatalog,
diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json
index a9553691acae7..64b18a4b02c90 100644
--- a/packages/provider-opencode/package.json
+++ b/packages/provider-opencode/package.json
@@ -42,6 +42,10 @@
"./server/v2/textGeneration": {
"types": "./src/server/v2/textGeneration.ts",
"import": "./src/server/v2/textGeneration.ts"
+ },
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
}
},
"scripts": {
diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts
index bff49126ce4d4..2d21a7267b2a6 100644
--- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts
+++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts
@@ -1,17 +1,16 @@
import * as NodeURL from "node:url";
import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
-import {
- createOpencodeClient,
- type Agent,
- type Command,
- type FilePartInput,
- type Model,
- type OpencodeClient,
- type PermissionRuleset,
- type ProviderListResponse,
- type QuestionAnswer,
- type QuestionRequest,
+import type {
+ Agent,
+ Command,
+ FilePartInput,
+ Model,
+ OpencodeClient,
+ PermissionRuleset,
+ ProviderListResponse,
+ QuestionAnswer,
+ QuestionRequest,
} from "@opencode-ai/sdk/v2";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
@@ -285,7 +284,7 @@ export class OpenCodeRuntime extends Context.Service<
readonly baseUrl: string;
readonly directory: string;
readonly serverPassword?: string;
- }) => OpencodeClient;
+ }) => Effect.Effect;
readonly loadOpenCodeInventory: (
client: OpencodeClient,
) => Effect.Effect;
@@ -658,18 +657,22 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
);
const createOpenCodeSdkClient: OpenCodeRuntime["Service"]["createOpenCodeSdkClient"] = (input) =>
- createOpencodeClient({
- baseUrl: input.baseUrl,
- directory: input.directory,
- ...(input.serverPassword
- ? {
- headers: {
- Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
- },
- }
- : {}),
- throwOnError: true,
- });
+ Effect.promise(() => import("@opencode-ai/sdk/v2")).pipe(
+ Effect.map(({ createOpencodeClient }) =>
+ createOpencodeClient({
+ baseUrl: input.baseUrl,
+ directory: input.directory,
+ ...(input.serverPassword
+ ? {
+ headers: {
+ Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
+ },
+ }
+ : {}),
+ throwOnError: true,
+ }),
+ ),
+ );
const startServerAttempt: OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] = (input) =>
Effect.gen(function* () {
@@ -860,13 +863,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
const url = readyOption.value;
const version = yield* (
input.verify?.(url) ??
- verifyOpenCodeServerVersion(
- createOpenCodeSdkClient({
- baseUrl: url,
- directory: input.directory,
- ...(serverPassword !== undefined ? { serverPassword } : {}),
- }),
- )
+ createOpenCodeSdkClient({
+ baseUrl: url,
+ directory: input.directory,
+ ...(serverPassword !== undefined ? { serverPassword } : {}),
+ }).pipe(Effect.flatMap(verifyOpenCodeServerVersion))
);
return {
@@ -915,21 +916,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
external: true,
...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}),
});
- return verifyOpenCodeServerVersion(
- createOpenCodeSdkClient({
- baseUrl: serverUrl,
- directory: input.directory,
- ...(serverPassword !== undefined ? { serverPassword } : {}),
- }),
- ).pipe(
- Effect.map((version) => ({
- url: serverUrl,
- ...(serverPassword !== undefined ? { serverPassword } : {}),
- version,
- exitCode: null,
- external: true,
- })),
- );
+ return createOpenCodeSdkClient({
+ baseUrl: serverUrl,
+ directory: input.directory,
+ ...(serverPassword !== undefined ? { serverPassword } : {}),
+ })
+ .pipe(Effect.flatMap(verifyOpenCodeServerVersion))
+ .pipe(
+ Effect.map((version) => ({
+ url: serverUrl,
+ ...(serverPassword !== undefined ? { serverPassword } : {}),
+ version,
+ exitCode: null,
+ external: true,
+ })),
+ );
}
return startOpenCodeServerProcess({
diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts
index 72348b98aa779..e8d440017d716 100644
--- a/packages/provider-opencode/src/server/adapter.test.ts
+++ b/packages/provider-opencode/src/server/adapter.test.ts
@@ -164,7 +164,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct
}).pipe(
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }),
- createOpenCodeSdkClient: () => client,
+ createOpenCodeSdkClient: () => Effect.succeed(client),
} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]),
Effect.provideService(ProviderHost.ProviderHost, {
paths: { cwd: "/workspace" },
@@ -1608,7 +1608,7 @@ describe("OpenCodeAdapterV2", () => {
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
connectToOpenCodeServer: () =>
Effect.succeed({ url: "http://test.invalid", external: true }),
- createOpenCodeSdkClient: () => client,
+ createOpenCodeSdkClient: () => Effect.succeed(client),
} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]),
Effect.provideService(ProviderHost.ProviderHost, {
paths: { cwd: "/workspace" },
@@ -1905,7 +1905,7 @@ describe("OpenCodeAdapterV2", () => {
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, {
connectToOpenCodeServer: () =>
Effect.succeed({ url: "http://test.invalid", external: true }),
- createOpenCodeSdkClient: () => client,
+ createOpenCodeSdkClient: () => Effect.succeed(client),
} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]),
Effect.provideService(ProviderHost.ProviderHost, {
paths: { cwd: "/workspace" },
@@ -2587,7 +2587,7 @@ describe("OpenCodeAdapterV2", () => {
external: true,
}),
runOpenCodeCommand: unused("runOpenCodeCommand"),
- createOpenCodeSdkClient: () => fakeClient,
+ createOpenCodeSdkClient: () => Effect.succeed(fakeClient),
loadOpenCodeInventory: unused("loadOpenCodeInventory"),
loadInventoryFromCli: unused("loadInventoryFromCli"),
loadOpenCodeSkills: unused("loadOpenCodeSkills"),
diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts
index 71144f24dd70c..a753ba565a5e3 100644
--- a/packages/provider-opencode/src/server/adapter.ts
+++ b/packages/provider-opencode/src/server/adapter.ts
@@ -949,7 +949,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function
serverUrl: options.settings.serverUrl,
environment: options.environment,
});
- const client = runtime.createOpenCodeSdkClient({
+ const client = yield* runtime.createOpenCodeSdkClient({
baseUrl: connection.url,
directory: cwd,
...(connection.external && options.settings.serverPassword
diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts
index 3b6419ad4a59c..381c186315b5f 100644
--- a/packages/provider-opencode/src/server/driver.test.ts
+++ b/packages/provider-opencode/src/server/driver.test.ts
@@ -9,7 +9,6 @@ import { ProviderInstanceId } from "@t3tools/contracts";
import type { OpenCodeSettings } from "../settings.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
-import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
@@ -27,6 +26,7 @@ import {
replayOpenCodeServer,
} from "./probeResponses.fixture.ts";
import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts";
+import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
const serverStarts: Array = [];
@@ -74,7 +74,15 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
it.effect("never starts a 1.x server for an OpenCode 2 instance", () =>
Effect.gen(function* () {
serverStarts.length = 0;
- const instance = yield* create({}, noHttp);
+ const host = yield* ProviderHost.ProviderHost;
+ const instance = yield* create({}, noHttp).pipe(
+ Effect.provideService(ProviderHost.ProviderHost, {
+ ...host,
+ awaitStartupProbe: Effect.never,
+ }),
+ );
+ // Keep the startup status probe gated, as it is before the server is ready.
+ // Text generation goes to the instance's 2.x server, which this test refuses to start.
yield* Effect.flip(
instance.textGeneration.generateThreadTitle({
cwd: process.cwd(),
@@ -119,12 +127,10 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
const hang = HttpClient.make(() => Effect.never);
const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang);
- const capabilities = yield* instance.orchestrationAdapter
- .getCapabilities()
- .pipe(Effect.forkChild);
- yield* Effect.yieldNow;
- assert.isDefined(capabilities.pollUnsafe());
- yield* Fiber.join(capabilities);
+ // No probe has succeeded yet, and the server never answers: the 1.x default applies.
+ // First use may load the adapter, but completes without advancing the probe's clock.
+ const capabilities = yield* instance.orchestrationAdapter.getCapabilities();
+ assert.isDefined(capabilities);
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);
});
diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts
index 9c522d554ce0c..349d58e248a26 100644
--- a/packages/provider-opencode/src/server/driver.ts
+++ b/packages/provider-opencode/src/server/driver.ts
@@ -2,7 +2,9 @@ import { ProviderDriverKind } from "@t3tools/contracts";
import { OpenCodeSettings } from "../settings.ts";
import * as Crypto from "effect/Crypto";
import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
import * as Option from "effect/Option";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
@@ -11,12 +13,12 @@ import * as Stream from "effect/Stream";
import * as HttpClient from "effect/http/HttpClient";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
-import * as OpenCode2TextGeneration from "./v2/textGeneration.ts";
import { makeOpenCodeTextGeneration } from "./textGeneration.ts";
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
-import * as OpenCodeAdapterV2 from "./adapter.ts";
-import * as OpenCode2AdapterV2 from "./v2/adapter.ts";
+import type * as OpenCodeAdapterV2 from "./adapter.ts";
+import { makeLazyAdapter } from "@t3tools/provider-core/server/lazyAdapter";
+
import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
@@ -247,22 +249,30 @@ export const OpenCodeDriver: ProviderDriver<
Effect.andThen(cachedMaintenance({ fresh: true })),
)
: cachedMaintenance();
- const openCodeV1Adapter = yield* OpenCodeAdapterV2.OpenCodeAdapterV2Driver.create({
+ const openCodeV1Adapter = yield* makeLazyAdapter(
instanceId,
- displayName,
- accentColor,
- environment,
- enabled,
- config,
- }).pipe(
- Effect.mapError(
- (cause) =>
- new ProviderDriverError({
- driver: DRIVER_KIND,
+ DRIVER_KIND,
+ Effect.promise(() => import("./adapter.ts")).pipe(
+ Effect.flatMap(({ OpenCodeAdapterV2Driver }) =>
+ OpenCodeAdapterV2Driver.create({
instanceId,
- detail: "Failed to build OpenCode orchestration adapter.",
- cause,
- }),
+ displayName,
+ accentColor,
+ environment,
+ enabled,
+ config,
+ }).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Failed to build OpenCode orchestration adapter.",
+ cause,
+ }),
+ ),
+ ),
+ ),
),
);
const openCode2Server = yield* OpenCode2Server.make({
@@ -283,8 +293,13 @@ export const OpenCodeDriver: ProviderDriver<
const orchestrationAdapter = selectOpenCodeRuntimeAdapter({
probe: runtimeProbe,
v1: openCodeV1Adapter,
- v2: yield* OpenCode2AdapterV2.make(instanceId).pipe(
- Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
+ v2: yield* makeLazyAdapter(
+ instanceId,
+ DRIVER_KIND,
+ Effect.promise(() => import("./v2/adapter.ts")).pipe(
+ Effect.flatMap((adapter) => adapter.make(instanceId)),
+ Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
+ ),
),
});
const loadOpenCode2Models = yield* makeOpenCode2ModelLoader(
@@ -338,9 +353,37 @@ export const OpenCodeDriver: ProviderDriver<
yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
),
- yield* OpenCode2TextGeneration.make().pipe(
- Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server),
- ),
+ yield* Effect.gen(function* () {
+ const context =
+ yield* Effect.context<
+ Exclude<
+ Effect.Services