diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 3693dcb460..179345cb48 100644 --- a/packages/app/src/context/global-sync/session-load.ts +++ b/packages/app/src/context/global-sync/session-load.ts @@ -1,4 +1,7 @@ +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { sessionUpdatedAt, trimSessions } from "./session-trim" import type { RootLoadArgs } from "./types" +import { directoryKey } from "./utils" export async function loadRootSessionsWithFallback(input: RootLoadArgs) { try { @@ -23,3 +26,40 @@ export function estimateRootSessionTotal(input: { count: number; limit: number; if (input.count < input.limit) return input.count return input.count + 1 } + +export function mergeRootSessionLoad(input: { + directory: string + loadedAt: number + listed: Session[] + current: Session[] + limit: number + permission: Record +}) { + const directory = directoryKey(input.directory) + const currentRoots = input.current.filter( + (session) => !session.parentID && !session.time?.archived && directoryKey(session.directory) === directory, + ) + const byID = new Map( + input.listed + .filter((session) => !!session?.id) + .filter((session) => !session.time?.archived) + .map((session) => [session.id, session] as const), + ) + + // A list response can resolve after live create/update events have already seeded this store. + for (const session of currentRoots) { + const listed = byID.get(session.id) + if (!listed) { + if (sessionUpdatedAt(session) >= input.loadedAt) byID.set(session.id, session) + continue + } + if (sessionUpdatedAt(session) >= input.loadedAt && sessionUpdatedAt(session) > sessionUpdatedAt(listed)) { + byID.set(session.id, session) + } + } + + return trimSessions([...byID.values(), ...input.current.filter((session) => !!session.parentID)], { + limit: input.limit, + permission: input.permission, + }) +} diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 93e9c41755..e65118ce2b 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,6 +1,35 @@ import { describe, expect, test } from "bun:test" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { + estimateRootSessionTotal, + loadRootSessionsWithFallback, + mergeRootSessionLoad, +} from "./global-sync/session-load" + +function session(input: { + id: string + directory?: string + created?: number + updated?: number + parentID?: string + archived?: number +}) { + return { + id: input.id, + directory: input.directory ?? "/repo", + projectID: "project", + title: input.id, + parentID: input.parentID, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: input.created ?? 1_000, + updated: input.updated ?? input.created ?? 1_000, + archived: input.archived, + }, + } as Session +} describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -63,6 +92,48 @@ describe("loadRootSessionsWithFallback", () => { }) }) +describe("mergeRootSessionLoad", () => { + test("keeps sessions created while a stale list request was in flight", () => { + const result = mergeRootSessionLoad({ + directory: "/repo", + loadedAt: 2_000, + listed: [], + current: [session({ id: "new", created: 2_100 })], + limit: 5, + permission: {}, + }) + + expect(result.map((item) => item.id)).toEqual(["new"]) + }) + + test("keeps newer live session data over stale list responses", () => { + const result = mergeRootSessionLoad({ + directory: "/repo", + loadedAt: 2_000, + listed: [session({ id: "same", updated: 1_500 })], + current: [{ ...session({ id: "same", updated: 2_100 }), title: "new title" }], + limit: 5, + permission: {}, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.title).toBe("new title") + }) + + test("allows authoritative loads to remove old missing roots", () => { + const result = mergeRootSessionLoad({ + directory: "/repo", + loadedAt: 2_000, + listed: [], + current: [session({ id: "old", updated: 1_500 })], + limit: 5, + permission: {}, + }) + + expect(result).toEqual([]) + }) +}) + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa076..c2afee4f28 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -26,7 +26,11 @@ import { } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { + estimateRootSessionTotal, + loadRootSessionsWithFallback, + mergeRootSessionLoad, +} from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" @@ -273,6 +277,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { } const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) + const loadedAt = Date.now() const promise = queryClient .fetchQuery({ ...queryOptionsApi.sessions(key), @@ -283,13 +288,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { list: (query) => serverSDK.client.session.list(query), }) .then((x) => { - const nonArchived = (x.data ?? []) - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const listed = (x.data ?? []).filter((s) => !!s?.id).filter((s) => !s.time?.archived) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) - const childSessions = store.session.filter((s) => !!s.parentID) - const next = trimSessions([...nonArchived, ...childSessions], { + const next = mergeRootSessionLoad({ + directory, + loadedAt, + listed, + current: store.session, limit, permission: session.data.permission, }) @@ -297,11 +302,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { next.forEach(session.remember) setStore( "sessionTotal", - estimateRootSessionTotal({ - count: nonArchived.length, - limit: x.limit, - limited: x.limited, - }), + Math.max( + estimateRootSessionTotal({ + count: listed.length, + limit: x.limit, + limited: x.limited, + }), + next.filter((item) => !item.parentID).length, + ), ) setStore("session", reconcile(next, { key: "id" })) })