Compare commits

...

1 Commits

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
@@ -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<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,
})
}
+72 -1
View File
@@ -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)
+20 -12
View File
@@ -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" }))
})