Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Brendan Allan
a35328aab1 fix(app): keep live sessions during list refresh
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 15:26:33 +00:00
3 changed files with 132 additions and 13 deletions

View file

@ -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 type { RootLoadArgs } from "./types"
import { directoryKey } from "./utils"
export async function loadRootSessionsWithFallback(input: RootLoadArgs) { export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
try { try {
@ -23,3 +26,40 @@ export function estimateRootSessionTotal(input: { count: number; limit: number;
if (input.count < input.limit) return input.count if (input.count < input.limit) return input.count
return input.count + 1 return input.count + 1
} }
export function mergeRootSessionLoad(input: {
directory: string
loadedAt: number
listed: Session[]
current: Session[]
limit: number
permission: Record<string, PermissionRequest[]>
}) {
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,
})
}

View file

@ -1,6 +1,35 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" 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", () => { describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => { 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", () => { describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => { test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)

View file

@ -26,7 +26,11 @@ import {
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" 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 { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } 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 limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const loadedAt = Date.now()
const promise = queryClient const promise = queryClient
.fetchQuery({ .fetchQuery({
...queryOptionsApi.sessions(key), ...queryOptionsApi.sessions(key),
@ -283,13 +288,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
list: (query) => serverSDK.client.session.list(query), list: (query) => serverSDK.client.session.list(query),
}) })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const listed = (x.data ?? []).filter((s) => !!s?.id).filter((s) => !s.time?.archived)
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const childSessions = store.session.filter((s) => !!s.parentID) const next = mergeRootSessionLoad({
const next = trimSessions([...nonArchived, ...childSessions], { directory,
loadedAt,
listed,
current: store.session,
limit, limit,
permission: session.data.permission, permission: session.data.permission,
}) })
@ -297,11 +302,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
next.forEach(session.remember) next.forEach(session.remember)
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ Math.max(
count: nonArchived.length, estimateRootSessionTotal({
limit: x.limit, count: listed.length,
limited: x.limited, limit: x.limit,
}), limited: x.limited,
}),
next.filter((item) => !item.parentID).length,
),
) )
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
}) })