fix: lighten session list payloads (#1538)

* lighten session list

* fetch full session on open

* sanitize session list

* sanitize global sessions

* preserve revert markers in session lists

* fix: preserve session metadata in list sanitizers

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Hristo Karamanliev
2026-06-11 23:06:19 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1c274d0a56
commit 9685630436
8 changed files with 701 additions and 39 deletions
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages } from './globalSessions'
describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => {
const apiClient = {
experimental: {
session: {
list: async () => ({
data: [
{
id: 'ses_1',
directory: '/repo/app',
title: 'Alpha',
time: { created: 1, updated: 2 },
metadata: {
openchamber: {
kind: 'review',
originalSessionID: 'ses_original',
},
},
permission: [{ permission: 'todowrite' }],
revert: { messageID: 'msg_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
summary: {
additions: 5,
deletions: 3,
files: 2,
diffs: [{ patch: '@@ -1 +1 @@', additions: 5, deletions: 3 }],
},
},
],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 })
const session = sessions[0] as typeof sessions[number] & {
metadata?: unknown
permission?: unknown
revert?: { messageID?: string; snapshot?: string; diff?: string }
summary?: { additions?: number; deletions?: number; files?: number; diffs?: unknown[] }
}
expect(session.metadata).toEqual({
openchamber: {
kind: 'review',
originalSessionID: 'ses_original',
},
})
expect(session.permission).toBe(undefined)
expect(session.revert).toEqual({ messageID: 'msg_1' })
expect(session.summary).toEqual({ additions: 5, deletions: 3, files: 2 })
})
})
+3 -1
View File
@@ -1,5 +1,6 @@
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
import { retry } from "@/sync/retry";
import { stripSessionListDetails } from "@/sync/sanitize";
export type GlobalSessionRecord = Session & {
project?: {
@@ -121,7 +122,8 @@ export async function listGlobalSessionPages(
{ attempts: 3, delay: 500, retryIf: () => true },
);
const payload = unwrapSessionList(response, "experimental.session.list");
const payload = unwrapSessionList(response, "experimental.session.list")
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
if (payload.length === 0) break;
let appended = 0;
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, test } from 'bun:test'
import type { Session } from '@opencode-ai/sdk/v2'
import { stripSessionDiffSnapshots, stripSessionListDetails } from './sanitize'
describe('stripSessionDiffSnapshots', () => {
test('removes oversized revert and summary diff payloads', () => {
const session = {
id: 'ses_1',
slug: 'session-one',
projectID: 'proj_1',
directory: '/repo/app',
title: 'Session',
version: '1.0.0',
time: { created: 1, updated: 2 },
revert: {
messageID: 'msg_2',
partID: 'part_3',
snapshot: 'gitsha',
diff: 'diff --git a/file b/file',
},
summary: {
additions: 2,
deletions: 1,
files: 1,
diffs: [{ additions: 2, deletions: 1, before: 'a', after: 'b', patch: '@@ -1 +1 @@' }],
},
} as unknown as Session
const next = stripSessionDiffSnapshots(session) as Session & {
revert?: { messageID?: string; partID?: string; snapshot?: string; diff?: string }
summary?: { diffs?: Array<{ before?: string; after?: string; patch?: string }> }
}
expect(next).not.toBe(session)
expect(next.revert).toEqual({ messageID: 'msg_2', partID: 'part_3' })
expect(next.summary?.diffs).toEqual([{ additions: 2, deletions: 1 }])
})
test('preserves object identity when nothing changes', () => {
const session = {
id: 'ses_1',
slug: 'session-one',
projectID: 'proj_1',
directory: '/repo/app',
title: 'Session',
version: '1.0.0',
time: { created: 1, updated: 2 },
revert: { messageID: 'msg_2', partID: 'part_3' },
summary: { additions: 2, deletions: 1, diffs: [{ additions: 2, deletions: 1 }] },
} as unknown as Session
expect(stripSessionDiffSnapshots(session)).toBe(session)
})
})
describe('stripSessionListDetails', () => {
test('removes detail-only fields from session list records', () => {
const session = {
id: 'ses_1',
slug: 'session-one',
projectID: 'proj_1',
directory: '/repo/app',
title: 'Session',
time: { created: 1, updated: 2 },
metadata: {
openchamber: {
kind: 'review',
originalSessionID: 'ses_original',
},
},
permission: [{ permission: 'todowrite' }],
revert: {
messageID: 'msg_2',
partID: 'part_3',
snapshot: 'gitsha',
diff: 'diff --git a/file b/file',
},
summary: {
additions: 2,
deletions: 1,
files: 1,
diffs: [{ additions: 2, deletions: 1, patch: '@@ -1 +1 @@' }],
},
} as unknown as Session
const next = stripSessionListDetails(session) as Session & {
metadata?: unknown
permission?: unknown
revert?: { messageID?: string; partID?: string; snapshot?: string; diff?: string }
summary?: { additions?: number; deletions?: number; files?: number; diffs?: unknown[] }
}
expect(next).not.toBe(session)
expect(next.metadata).toEqual({
openchamber: {
kind: 'review',
originalSessionID: 'ses_original',
},
})
expect(next.permission).toBe(undefined)
expect(next.revert).toEqual({ messageID: 'msg_2', partID: 'part_3' })
expect(next.summary).toEqual({ additions: 2, deletions: 1, files: 1 })
})
test('preserves metadata extension fields in session list records', () => {
const session = {
id: 'ses_1',
directory: '/repo/app',
title: 'Session',
time: { created: 1, updated: 2 },
metadata: { custom: { value: 'kept' } },
summary: { additions: 2, deletions: 1, files: 1, diffs: [{ patch: '@@ -1 +1 @@' }] },
} as unknown as Session
const next = stripSessionListDetails(session) as Session & {
metadata?: unknown
summary?: { diffs?: unknown[] }
}
expect(next).not.toBe(session)
expect(next.metadata).toEqual({ custom: { value: 'kept' } })
expect(next.summary?.diffs).toBe(undefined)
})
test('keeps summary fields other than list-only diffs', () => {
const session = {
id: 'ses_1',
directory: '/repo/app',
title: 'Session',
time: { created: 1, updated: 2 },
summary: { custom: 'kept', diffs: [{ patch: '@@ -1 +1 @@' }] },
} as unknown as Session
const next = stripSessionListDetails(session) as Session & {
summary?: { custom?: string; diffs?: unknown[] }
}
expect(next.summary).toEqual({ custom: 'kept' })
})
test('preserves object identity for already lightweight records with revert markers', () => {
const session = {
id: 'ses_1',
directory: '/repo/app',
title: 'Session',
time: { created: 1, updated: 2 },
revert: { messageID: 'msg_2', partID: 'part_3' },
summary: { additions: 2, deletions: 1, files: 1 },
} as unknown as Session
expect(stripSessionListDetails(session)).toBe(session)
})
})
+127 -32
View File
@@ -2,13 +2,16 @@
// Payload sanitization — strip oversized diff snapshot fields client-side.
//
// OpenCode session/message snapshots may carry large full-content diff fields
// (legacy before/after or from/to). The UI never uses these fields but they
// waste browser memory and
// can crash tabs for large sessions.
// (legacy before/after or from/to). Revert snapshots and diff text can also
// carry large file snapshots. The UI derives reverted-state behavior from the
// lightweight messageID/partID markers, so these blob fields are intentionally
// kept out of client stores.
//
// Applied at two points:
// 1. Event reducer — session.created/session.updated events
// 2. Message loading — fetchMessages response
// 3. Session list loading — list responses should not populate stores with
// detail-only revert/diff blobs
// ---------------------------------------------------------------------------
import type { Session, Message } from "@opencode-ai/sdk/v2/client"
@@ -29,32 +32,134 @@ type SessionSummary = {
[key: string]: unknown
}
type SessionRevert = {
messageID?: string
partID?: string
snapshot?: string
diff?: string
[key: string]: unknown
}
const getSessionListRevertMarker = (revert: unknown): Pick<SessionRevert, "messageID" | "partID"> | undefined => {
if (!revert || typeof revert !== "object" || Array.isArray(revert)) {
return undefined
}
const marker: Pick<SessionRevert, "messageID" | "partID"> = {}
const record = revert as SessionRevert
if (typeof record.messageID === "string") {
marker.messageID = record.messageID
}
if (typeof record.partID === "string") {
marker.partID = record.partID
}
return Object.keys(marker).length > 0 ? marker : undefined
}
const hasSessionListRevertDetails = (revert: unknown): boolean => {
if (revert === undefined) {
return false
}
if (!revert || typeof revert !== "object" || Array.isArray(revert)) {
return true
}
return Object.keys(revert).some((key) => key !== "messageID" && key !== "partID")
}
const stripDiffSnapshotFields = <T extends DiffEntry>(diff: T, includePatch: boolean): T => {
if (!diff || typeof diff !== "object") {
return diff
}
const shouldStrip =
typeof diff.before === "string"
|| typeof diff.after === "string"
|| typeof diff.from === "string"
|| typeof diff.to === "string"
|| (includePatch && "patch" in (diff as T & { patch?: unknown }) && typeof (diff as T & { patch?: unknown }).patch === "string")
if (!shouldStrip) {
return diff
}
const rest = { ...diff } as T & { patch?: string }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
if (includePatch) {
delete rest.patch
}
return rest
}
/** Strip oversized snapshot fields from summary.diffs on a session object */
export function stripSessionDiffSnapshots(session: Session): Session {
const summary = (session as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return session
const revert = (session as { revert?: SessionRevert }).revert
let nextSession: Session = session
let changed = false
if (revert && (typeof revert.snapshot === "string" || typeof revert.diff === "string")) {
const nextRevert = { ...revert }
delete nextRevert.snapshot
delete nextRevert.diff
nextSession = { ...nextSession, revert: nextRevert } as Session
changed = true
}
if (!summary?.diffs || !Array.isArray(summary.diffs)) return nextSession
const stripped = summary.diffs.map((d) => {
if (d && (
typeof d.before === "string"
|| typeof d.after === "string"
|| typeof d.from === "string"
|| typeof d.to === "string"
)) {
const rest = { ...d }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
const nextDiff = stripDiffSnapshotFields(d, true)
if (nextDiff !== d) {
changed = true
return rest
}
return d
return nextDiff
})
if (!changed) return session
return { ...session, summary: { ...summary, diffs: stripped } } as Session
if (!changed) return nextSession
return { ...nextSession, summary: { ...summary, diffs: stripped } } as Session
}
/** Strip detail-only fields from session list records before storing them. */
export function stripSessionListDetails(session: Session): Session {
const record = session as Session & {
summary?: SessionSummary
revert?: unknown
permission?: unknown
}
const shouldStrip = hasSessionListRevertDetails(record.revert)
|| Array.isArray(record.summary?.diffs)
|| "permission" in record
if (!shouldStrip) {
return session
}
const stripped = stripSessionDiffSnapshots(session) as typeof record
const next: Record<string, unknown> = { ...stripped }
delete next.permission
const revertMarker = getSessionListRevertMarker(stripped.revert)
if (revertMarker) {
next.revert = revertMarker
} else {
delete next.revert
}
const summary = stripped.summary
if (summary && typeof summary === "object" && !Array.isArray(summary)) {
const summaryWithoutDiffs = { ...summary }
delete summaryWithoutDiffs.diffs
next.summary = summaryWithoutDiffs
}
return next as unknown as Session
}
/** Strip oversized snapshot fields from summary.diffs on a message object */
@@ -64,21 +169,11 @@ export function stripMessageDiffSnapshots(message: Message): Message {
let changed = false
const stripped = summary.diffs.map((d) => {
if (d && (
typeof d.before === "string"
|| typeof d.after === "string"
|| typeof d.from === "string"
|| typeof d.to === "string"
)) {
const rest = { ...d }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
const nextDiff = stripDiffSnapshotFields(d, false)
if (nextDiff !== d) {
changed = true
return rest
}
return d
return nextDiff
})
if (!changed) return message
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import { shouldFetchSessionForRenderableSync } from './use-sync'
describe('shouldFetchSessionForRenderableSync', () => {
test('fetches full session detail when a lightweight list session is opened', () => {
expect(shouldFetchSessionForRenderableSync({
hasSession: true,
shouldLoadMessages: true,
force: false,
})).toBe(true)
})
test('skips session detail fetch when session and messages are already ready', () => {
expect(shouldFetchSessionForRenderableSync({
hasSession: true,
shouldLoadMessages: false,
force: false,
})).toBe(false)
})
test('fetches when the session record is missing', () => {
expect(shouldFetchSessionForRenderableSync({
hasSession: false,
shouldLoadMessages: false,
force: false,
})).toBe(true)
})
})
+14 -5
View File
@@ -10,7 +10,7 @@ import {
} from "./optimistic"
import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripMessageDiffSnapshots } from "./sanitize"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import {
@@ -121,6 +121,14 @@ function hasUserMessage(messages: Message[] | undefined): boolean {
return Boolean(messages?.some(isUserMessage))
}
export function shouldFetchSessionForRenderableSync(input: {
hasSession: boolean
shouldLoadMessages: boolean
force?: boolean
}): boolean {
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
}
// ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates
@@ -429,8 +437,8 @@ export function useSync() {
})) return
}
const shouldFetchSession = !hasSession || force
const shouldLoadMessages = !cachedReady || force
const shouldLoadMessages = Boolean(!cachedReady || force)
const shouldFetchSession = shouldFetchSessionForRenderableSync({ hasSession, shouldLoadMessages, force: Boolean(force) })
const promise = (async () => {
await Promise.all([
shouldFetchSession
@@ -442,13 +450,14 @@ export function useSync() {
return response
})
if (result.data) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = store.getState()
const sessions = [...s.session]
const idx = Binary.search(sessions, sessionID, (s) => s.id)
if (idx.found) {
sessions[idx.index] = result.data
sessions[idx.index] = nextSession
} else {
sessions.splice(idx.index, 0, result.data)
sessions.splice(idx.index, 0, nextSession)
}
store.setState({ session: sessions })
}