Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export type CreateDataInput = {
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const messagePageLimit = 20

// Sessions whose cached transcript outgrows this bound release their cache when the session
// view unmounts, so re-entering the session reduces one fresh page instead of every page the
// user ever scrolled through.
export const messageCacheReleaseLimit = messagePageLimit * 20

// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
Expand Down Expand Up @@ -1459,6 +1464,9 @@ export function createData(config: CreateDataInput) {
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
// A release or re-sync while this page was in flight replaced the cursor we started
// from; applying the response would splice a stale page into the fresh window.
if (store.session.messageCursor[sessionID] !== cursor) return
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
Expand All @@ -1470,6 +1478,18 @@ export function createData(config: CreateDataInput) {
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
},
release(sessionID: string) {
messageIndex.delete(sessionID)
// Invalidate so the next visit re-syncs instead of replaying a resolved sync.
sync.invalidate(`session.message:${sessionID}`)
setStore(
"session",
produce((draft) => {
delete draft.message[sessionID]
delete draft.messageCursor[sessionID]
}),
)
},
},
permission: {
list(sessionID: string) {
Expand Down
147 changes: 147 additions & 0 deletions packages/client/test/solid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,150 @@ async function wait(check: () => boolean) {
await Bun.sleep(10)
}
}

test("releases an oversized transcript cache and resyncs a fresh page on re-entry", async () => {
const total = 120
const pageLimit = 20
const messages = Array.from({ length: total }, (_, index) => ({
id: `msg_${String(index).padStart(4, "0")}`,
type: "user" as const,
text: `message ${index}`,
time: { created: index },
}))
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
if (url.pathname !== "/api/session/ses_scroll/message") throw new Error(`Unexpected request: ${request.url}`)
requests++
const k = Number(url.searchParams.get("cursor") ?? 0)
const slice = messages.slice(Math.max(0, total - (k + 1) * pageLimit), total - k * pageLimit)
const next = (k + 1) * pageLimit < total ? String(k + 1) : undefined
return Response.json({ data: slice.toReversed(), cursor: { next } })
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen: () => () => {},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))

try {
await setup.data.session.message.sync("ses_scroll")
expect(setup.data.session.message.list("ses_scroll").map((item) => item.id)).toEqual(
messages.slice(total - pageLimit).map((item) => item.id),
)
await setup.data.session.message.loadMore("ses_scroll")
await setup.data.session.message.loadMore("ses_scroll")
expect(setup.data.session.message.list("ses_scroll")).toHaveLength(60)
expect(setup.data.session.message.more("ses_scroll")).toBe(true)

setup.data.session.message.release("ses_scroll")
expect(setup.data.session.message.list("ses_scroll")).toEqual([])
expect(setup.data.session.message.more("ses_scroll")).toBe(false)

const before = requests
await setup.data.session.message.sync("ses_scroll")
expect(requests).toBeGreaterThan(before)
expect(setup.data.session.message.list("ses_scroll")).toHaveLength(pageLimit)
expect(setup.data.session.message.list("ses_scroll")[0]?.id).toBe(`msg_${String(total - pageLimit).padStart(4, "0")}`)
} finally {
setup.dispose()
}
})

test("reaches the complete transcript in order after a release", async () => {
const total = 120
const pageLimit = 20
const messages = Array.from({ length: total }, (_, index) => ({
id: `msg_${String(index).padStart(4, "0")}`,
type: "user" as const,
text: `message ${index}`,
time: { created: index },
}))
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
if (url.pathname !== "/api/session/ses_scroll/message") throw new Error(`Unexpected request: ${request.url}`)
const k = Number(url.searchParams.get("cursor") ?? 0)
const slice = messages.slice(Math.max(0, total - (k + 1) * pageLimit), total - k * pageLimit)
const next = (k + 1) * pageLimit < total ? String(k + 1) : undefined
return Response.json({ data: slice.toReversed(), cursor: { next } })
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen: () => () => {},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))

try {
await setup.data.session.message.sync("ses_scroll")
await setup.data.session.message.loadMore("ses_scroll")
setup.data.session.message.release("ses_scroll")
await setup.data.session.message.sync("ses_scroll")
while (setup.data.session.message.more("ses_scroll")) await setup.data.session.message.loadMore("ses_scroll")

const ids = setup.data.session.message.list("ses_scroll").map((item) => item.id)
expect(ids).toEqual(messages.map((item) => item.id))
} finally {
setup.dispose()
}
})

test("drops an in-flight older page when its cursor was replaced by a release", async () => {
const total = 120
const pageLimit = 20
const messages = Array.from({ length: total }, (_, index) => ({
id: `msg_${String(index).padStart(4, "0")}`,
type: "user" as const,
text: `message ${index}`,
time: { created: index },
}))
let releaseGate!: () => void
const gated = new Promise<void>((resolve) => (releaseGate = resolve))
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
if (url.pathname !== "/api/session/ses_scroll/message") throw new Error(`Unexpected request: ${request.url}`)
const k = Number(url.searchParams.get("cursor") ?? 0)
if (k > 0) await gated
const slice = messages.slice(Math.max(0, total - (k + 1) * pageLimit), total - k * pageLimit)
const next = (k + 1) * pageLimit < total ? String(k + 1) : undefined
return Response.json({ data: slice.toReversed(), cursor: { next } })
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen: () => () => {},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))

try {
await setup.data.session.message.sync("ses_scroll")
const pending = setup.data.session.message.loadMore("ses_scroll")
await Bun.sleep(20)
setup.data.session.message.release("ses_scroll")
releaseGate()
await pending
expect(setup.data.session.message.list("ses_scroll")).toEqual([])
expect(setup.data.session.message.more("ses_scroll")).toBe(false)
} finally {
setup.dispose()
}
})
11 changes: 11 additions & 0 deletions packages/tui/src/routes/session/rows.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SessionInboxEnqueued, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { messageCacheReleaseLimit } from "@opencode-ai/client/solid"
import { useConfig } from "../../config"
import { useData } from "../../context/data"
import { useClient } from "../../context/client"
Expand Down Expand Up @@ -42,6 +43,10 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const client = useClient()
const config = useConfig()
const [rows, setRows] = createStore<SessionRow[]>([])
// The session view is keyed per session, so this instance only ever shows one session.
// Capture it at creation: by teardown time a shared route store already points at the
// destination, and releasing against the live accessor would drop the wrong cache.
const mounted = sessionID()
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)

Expand Down Expand Up @@ -280,6 +285,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
onCleanup(() => {
// Leaving a deeply scrolled session drops its cache so the next visit paints from one
// fresh page instead of reducing every page loaded during the previous visit.
if (data.session.message.list(mounted).length <= messageCacheReleaseLimit) return
data.session.message.release(mounted)
})

return rows
}
Expand Down
156 changes: 156 additions & 0 deletions packages/tui/test/cli/tui/session-switch-perf.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { expect, test } from "bun:test"
import { Show, createRoot, createSignal } from "solid-js"
import { messageCacheReleaseLimit } from "@opencode-ai/client/solid"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { createSessionRows } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"

const sessionID = "session-switch-perf"
const destination = "session-switch-destination"
// Stands in for the shared route store: a switch writes the destination before the outgoing
// view is disposed, so teardown must not read this to learn which session it was showing.
const route = { sessionID }
const total = 5_000
const pageLimit = 20
const messages = Array.from({ length: total }, (_, index) => ({
id: `msg_${String(index).padStart(6, "0")}`,
type: "user" as const,
text: `message ${index}`,
time: { created: index },
}))

// Page k counts back from the newest message; data is newest-first, matching order "desc".
const page = (k: number) => {
const end = total - k * pageLimit
return messages.slice(Math.max(0, end - pageLimit), end).toReversed()
}

const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) {
const k = Number(url.searchParams.get("cursor") ?? 0)
const next = (k + 1) * pageLimit < total ? String(k + 1) : undefined
return json({ data: page(k), cursor: { next } })
}
return undefined
}, events)

const config = createTuiResolvedConfig()

// The data provider stays mounted across Probe disposals, mirroring a session switch that
// remounts the session view while the app keeps its data layer.
const [present, setPresent] = createSignal(true)

type Harness = {
data: ReturnType<typeof useData>
client: ReturnType<typeof useClient>
rows: ReturnType<typeof createSessionRows>
synced: () => number
dispose: () => void
}

function mount(): Harness {
let syncedCount = 0
let data!: ReturnType<typeof useData>
let client!: ReturnType<typeof useClient>
let rows!: ReturnType<typeof createSessionRows>
function Probe() {
data = useData()
client = useClient()
rows = createSessionRows(() => route.sessionID, () => syncedCount++)
return null
}

const dispose = createRoot((dispose) => {
// Provider-only tree: no opentui elements, so nothing needs a terminal renderer.
const tree = (
<TestTuiContexts>
<ConfigProvider config={config}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProviderBase directory={process.cwd()}>
<Show when={present()}>
<Probe />
</Show>
</DataProviderBase>
</ClientProvider>
</ConfigProvider>
</TestTuiContexts>
)
void tree
return dispose
})
return {
get data() {
return data
},
get client() {
return client
},
get rows() {
return rows
},
synced: () => syncedCount,
dispose,
}
}

async function wait(fn: () => boolean, timeout = 4_000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(5)
}
}

test("repaints a released session from one fresh page and stays bounded", async () => {
route.sessionID = sessionID
const app = mount()
try {
await wait(() => app.client.connection.status() === "connected")
await wait(() => app.synced() > 0)
expect(app.data.session.message.list(sessionID)).toHaveLength(pageLimit)

while (app.data.session.message.more(sessionID) && app.data.session.message.list(sessionID).length < 460)
await app.data.session.message.loadMore(sessionID)
expect(app.data.session.message.list(sessionID).length).toBeGreaterThan(messageCacheReleaseLimit)

route.sessionID = destination
setPresent(false)
await wait(() => app.data.session.message.list(sessionID).length === 0)
expect(app.data.session.message.list(destination)).toHaveLength(0)
expect(app.data.session.message.more(sessionID)).toBe(false)

const reentry = performance.now()
route.sessionID = sessionID
setPresent(true)
await wait(() => app.synced() >= 2)
const elapsed = performance.now() - reentry
console.log(`[session-switch-perf] re-entry repaint of a ${total}-message session: ${elapsed.toFixed(1)}ms`)
expect(app.data.session.message.list(sessionID)).toHaveLength(pageLimit)
expect(app.rows.length).toBe(pageLimit)
expect(elapsed).toBeLessThan(5_000)
} finally {
app.dispose()
}
})

test("keeps a small transcript cache across leaving the session view", async () => {
route.sessionID = sessionID
const app = mount()
try {
await wait(() => app.client.connection.status() === "connected")
await wait(() => app.synced() > 0)
expect(app.data.session.message.list(sessionID)).toHaveLength(pageLimit)

setPresent(false)
await Bun.sleep(50)
expect(app.data.session.message.list(sessionID)).toHaveLength(pageLimit)
expect(app.data.session.message.more(sessionID)).toBe(true)
} finally {
app.dispose()
}
})
Loading