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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1c274d0a56
commit
9685630436
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
||||||
import { retry } from "@/sync/retry";
|
import { retry } from "@/sync/retry";
|
||||||
|
import { stripSessionListDetails } from "@/sync/sanitize";
|
||||||
|
|
||||||
export type GlobalSessionRecord = Session & {
|
export type GlobalSessionRecord = Session & {
|
||||||
project?: {
|
project?: {
|
||||||
@@ -121,7 +122,8 @@ export async function listGlobalSessionPages(
|
|||||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
{ 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;
|
if (payload.length === 0) break;
|
||||||
|
|
||||||
let appended = 0;
|
let appended = 0;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
// Payload sanitization — strip oversized diff snapshot fields client-side.
|
// Payload sanitization — strip oversized diff snapshot fields client-side.
|
||||||
//
|
//
|
||||||
// OpenCode session/message snapshots may carry large full-content diff fields
|
// 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
|
// (legacy before/after or from/to). Revert snapshots and diff text can also
|
||||||
// waste browser memory and
|
// carry large file snapshots. The UI derives reverted-state behavior from the
|
||||||
// can crash tabs for large sessions.
|
// lightweight messageID/partID markers, so these blob fields are intentionally
|
||||||
|
// kept out of client stores.
|
||||||
//
|
//
|
||||||
// Applied at two points:
|
// Applied at two points:
|
||||||
// 1. Event reducer — session.created/session.updated events
|
// 1. Event reducer — session.created/session.updated events
|
||||||
// 2. Message loading — fetchMessages response
|
// 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"
|
import type { Session, Message } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -29,32 +32,134 @@ type SessionSummary = {
|
|||||||
[key: string]: unknown
|
[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 */
|
/** Strip oversized snapshot fields from summary.diffs on a session object */
|
||||||
export function stripSessionDiffSnapshots(session: Session): Session {
|
export function stripSessionDiffSnapshots(session: Session): Session {
|
||||||
const summary = (session as { summary?: SessionSummary }).summary
|
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
|
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) => {
|
const stripped = summary.diffs.map((d) => {
|
||||||
if (d && (
|
const nextDiff = stripDiffSnapshotFields(d, true)
|
||||||
typeof d.before === "string"
|
if (nextDiff !== d) {
|
||||||
|| 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
|
|
||||||
changed = true
|
changed = true
|
||||||
return rest
|
|
||||||
}
|
}
|
||||||
return d
|
return nextDiff
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!changed) return session
|
if (!changed) return nextSession
|
||||||
return { ...session, summary: { ...summary, diffs: stripped } } as Session
|
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 */
|
/** Strip oversized snapshot fields from summary.diffs on a message object */
|
||||||
@@ -64,21 +169,11 @@ export function stripMessageDiffSnapshots(message: Message): Message {
|
|||||||
|
|
||||||
let changed = false
|
let changed = false
|
||||||
const stripped = summary.diffs.map((d) => {
|
const stripped = summary.diffs.map((d) => {
|
||||||
if (d && (
|
const nextDiff = stripDiffSnapshotFields(d, false)
|
||||||
typeof d.before === "string"
|
if (nextDiff !== d) {
|
||||||
|| 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
|
|
||||||
changed = true
|
changed = true
|
||||||
return rest
|
|
||||||
}
|
}
|
||||||
return d
|
return nextDiff
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!changed) return message
|
if (!changed) return message
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "./optimistic"
|
} from "./optimistic"
|
||||||
import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
||||||
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
||||||
import { stripMessageDiffSnapshots } from "./sanitize"
|
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||||
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||||
import {
|
import {
|
||||||
@@ -121,6 +121,14 @@ function hasUserMessage(messages: Message[] | undefined): boolean {
|
|||||||
return Boolean(messages?.some(isUserMessage))
|
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
|
// useSync — message loading, pagination, optimistic updates
|
||||||
// Message loading, pagination, optimistic updates
|
// Message loading, pagination, optimistic updates
|
||||||
@@ -429,8 +437,8 @@ export function useSync() {
|
|||||||
})) return
|
})) return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldFetchSession = !hasSession || force
|
const shouldLoadMessages = Boolean(!cachedReady || force)
|
||||||
const shouldLoadMessages = !cachedReady || force
|
const shouldFetchSession = shouldFetchSessionForRenderableSync({ hasSession, shouldLoadMessages, force: Boolean(force) })
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
shouldFetchSession
|
shouldFetchSession
|
||||||
@@ -442,13 +450,14 @@ export function useSync() {
|
|||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
|
const nextSession = stripSessionDiffSnapshots(result.data)
|
||||||
const s = store.getState()
|
const s = store.getState()
|
||||||
const sessions = [...s.session]
|
const sessions = [...s.session]
|
||||||
const idx = Binary.search(sessions, sessionID, (s) => s.id)
|
const idx = Binary.search(sessions, sessionID, (s) => s.id)
|
||||||
if (idx.found) {
|
if (idx.found) {
|
||||||
sessions[idx.index] = result.data
|
sessions[idx.index] = nextSession
|
||||||
} else {
|
} else {
|
||||||
sessions.splice(idx.index, 0, result.data)
|
sessions.splice(idx.index, 0, nextSession)
|
||||||
}
|
}
|
||||||
store.setState({ session: sessions })
|
store.setState({ session: sessions })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,69 @@ export const createSseBoundaryTracker = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SESSION_LIST_ALLOWED_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'slug',
|
||||||
|
'projectID',
|
||||||
|
'workspaceID',
|
||||||
|
'directory',
|
||||||
|
'path',
|
||||||
|
'parentID',
|
||||||
|
'title',
|
||||||
|
'agent',
|
||||||
|
'model',
|
||||||
|
'version',
|
||||||
|
'time',
|
||||||
|
'cost',
|
||||||
|
'tokens',
|
||||||
|
'share',
|
||||||
|
'metadata',
|
||||||
|
'project',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sanitizeSessionListItem = (session) => {
|
||||||
|
if (!session || typeof session !== 'object' || Array.isArray(session)) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = {};
|
||||||
|
for (const key of SESSION_LIST_ALLOWED_FIELDS) {
|
||||||
|
if (key in session) {
|
||||||
|
sanitized[key] = session[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = session.summary;
|
||||||
|
if (summary && typeof summary === 'object' && !Array.isArray(summary)) {
|
||||||
|
const summaryWithoutDiffs = { ...summary };
|
||||||
|
delete summaryWithoutDiffs.diffs;
|
||||||
|
sanitized.summary = summaryWithoutDiffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = session.revert;
|
||||||
|
if (revert && typeof revert === 'object' && !Array.isArray(revert)) {
|
||||||
|
const revertMarker = {};
|
||||||
|
if (typeof revert.messageID === 'string') {
|
||||||
|
revertMarker.messageID = revert.messageID;
|
||||||
|
}
|
||||||
|
if (typeof revert.partID === 'string') {
|
||||||
|
revertMarker.partID = revert.partID;
|
||||||
|
}
|
||||||
|
if (Object.keys(revertMarker).length > 0) {
|
||||||
|
sanitized.revert = revertMarker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeSessionListPayload = (payload) => {
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return payload.map((session) => sanitizeSessionListItem(session));
|
||||||
|
};
|
||||||
|
|
||||||
export const registerOpenCodeProxy = (app, deps) => {
|
export const registerOpenCodeProxy = (app, deps) => {
|
||||||
const {
|
const {
|
||||||
fs,
|
fs,
|
||||||
@@ -348,6 +411,57 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
|
||||||
|
try {
|
||||||
|
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
|
||||||
|
? req.originalUrl
|
||||||
|
: (typeof req.url === 'string' ? req.url : '');
|
||||||
|
const upstreamPathRaw = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
|
||||||
|
const upstreamPath = await canonicalizeDirectoryQuery(upstreamPathRaw);
|
||||||
|
const upstream = await fetch(buildOpenCodeUrl(upstreamPath, ''), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
...collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders()),
|
||||||
|
accept: 'application/json',
|
||||||
|
'accept-encoding': 'identity',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(upstream.status);
|
||||||
|
applyForwardProxyResponseHeaders(upstream.headers, res);
|
||||||
|
|
||||||
|
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
|
||||||
|
const bodyText = await upstream.text();
|
||||||
|
if (!contentType.toLowerCase().includes('application/json')) {
|
||||||
|
res.setHeader('content-type', contentType);
|
||||||
|
res.end(bodyText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(bodyText);
|
||||||
|
} catch {
|
||||||
|
res.setHeader('content-type', contentType);
|
||||||
|
res.end(bodyText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('content-type', contentType);
|
||||||
|
res.json(sanitizeSessionListPayload(payload));
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`[proxy] OpenCode ${logLabel} proxy error:`, error?.message ?? error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure API prefix is detected before proxying
|
// Ensure API prefix is detected before proxying
|
||||||
app.use('/api', (_req, _res, next) => {
|
app.use('/api', (_req, _res, next) => {
|
||||||
ensureOpenCodeApiPrefix();
|
ensureOpenCodeApiPrefix();
|
||||||
@@ -453,7 +567,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
return bTime - aTime;
|
return bTime - aTime;
|
||||||
});
|
});
|
||||||
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
|
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
|
||||||
return res.json(merged);
|
return res.json(sanitizeSessionListPayload(merged));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
|
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
|
||||||
next();
|
next();
|
||||||
@@ -461,9 +575,17 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/api/session', (req, res, next) => {
|
||||||
|
return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/global/event', forwardSseRequest);
|
app.get('/api/global/event', forwardSseRequest);
|
||||||
app.get('/api/event', forwardSseRequest);
|
app.get('/api/event', forwardSseRequest);
|
||||||
|
|
||||||
|
app.get('/api/experimental/session', (req, res, next) => {
|
||||||
|
return forwardSanitizedSessionListRequest(req, res, next, 'experimental.session');
|
||||||
|
});
|
||||||
|
|
||||||
// Generic proxy for non-SSE OpenCode API routes.
|
// Generic proxy for non-SSE OpenCode API routes.
|
||||||
const apiProxy = createProxyMiddleware({
|
const apiProxy = createProxyMiddleware({
|
||||||
target: resolveProxyTarget(),
|
target: resolveProxyTarget(),
|
||||||
|
|||||||
@@ -237,6 +237,199 @@ describe('OpenCode proxy SSE forwarding', () => {
|
|||||||
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
expect(Number(data.contentLength)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sanitizes experimental session list responses and forwards query params', async () => {
|
||||||
|
let seenQuery = null;
|
||||||
|
let seenAuth = null;
|
||||||
|
|
||||||
|
const upstream = express();
|
||||||
|
upstream.get('/experimental/session', (req, res) => {
|
||||||
|
seenQuery = req.query;
|
||||||
|
seenAuth = req.headers.authorization ?? null;
|
||||||
|
res.setHeader('X-Next-Cursor', '123');
|
||||||
|
res.json([
|
||||||
|
{
|
||||||
|
id: 'ses_1',
|
||||||
|
slug: 'alpha',
|
||||||
|
projectID: 'proj_1',
|
||||||
|
workspaceID: 'ws_1',
|
||||||
|
directory: '/repo/app',
|
||||||
|
path: '/repo/app',
|
||||||
|
parentID: 'ses_parent',
|
||||||
|
title: 'Alpha',
|
||||||
|
agent: 'build',
|
||||||
|
model: { id: 'gpt-5', providerID: 'openai', variant: 'default' },
|
||||||
|
version: '1.0.0',
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
cost: 7,
|
||||||
|
tokens: { input: 10, output: 20 },
|
||||||
|
share: { url: 'https://share.example/ses_1' },
|
||||||
|
project: { id: 'proj_1', worktree: '/repo/app' },
|
||||||
|
summary: {
|
||||||
|
additions: 5,
|
||||||
|
deletions: 3,
|
||||||
|
files: 2,
|
||||||
|
diffs: [{ patch: '@@ -1 +1 @@', additions: 5, deletions: 3 }],
|
||||||
|
},
|
||||||
|
metadata: { openchamber: { kind: 'review', originalSessionID: 'ses_original' } },
|
||||||
|
permission: [{ permission: 'todowrite', action: 'deny', pattern: '*' }],
|
||||||
|
revert: { messageID: 'msg_1', partID: 'part_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
upstreamServer = await listen(upstream);
|
||||||
|
const upstreamPort = upstreamServer.address().port;
|
||||||
|
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
registerOpenCodeProxy(app, {
|
||||||
|
fs: {
|
||||||
|
promises: {
|
||||||
|
realpath: async (value) => value === '/link/repo' ? '/real/repo' : value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
os: {},
|
||||||
|
path,
|
||||||
|
OPEN_CODE_READY_GRACE_MS: 0,
|
||||||
|
getRuntime: () => ({
|
||||||
|
openCodePort: upstreamPort,
|
||||||
|
openCodeBaseUrl: externalBaseUrl,
|
||||||
|
isOpenCodeReady: true,
|
||||||
|
openCodeNotReadySince: 0,
|
||||||
|
isRestartingOpenCode: false,
|
||||||
|
}),
|
||||||
|
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer session-token' }),
|
||||||
|
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||||
|
ensureOpenCodeApiPrefix: () => {},
|
||||||
|
});
|
||||||
|
proxyServer = await listen(app);
|
||||||
|
const proxyPort = proxyServer.address().port;
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/experimental/session?archived=false&limit=500&cursor=99&roots=true&directory=%2Flink%2Frepo`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('x-next-cursor')).toBe('123');
|
||||||
|
expect(seenAuth).toBe('Bearer session-token');
|
||||||
|
expect(seenQuery).toMatchObject({
|
||||||
|
archived: 'false',
|
||||||
|
limit: '500',
|
||||||
|
cursor: '99',
|
||||||
|
roots: 'true',
|
||||||
|
directory: '/real/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(response.json()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'ses_1',
|
||||||
|
slug: 'alpha',
|
||||||
|
projectID: 'proj_1',
|
||||||
|
workspaceID: 'ws_1',
|
||||||
|
directory: '/repo/app',
|
||||||
|
path: '/repo/app',
|
||||||
|
parentID: 'ses_parent',
|
||||||
|
title: 'Alpha',
|
||||||
|
agent: 'build',
|
||||||
|
model: { id: 'gpt-5', providerID: 'openai', variant: 'default' },
|
||||||
|
version: '1.0.0',
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
cost: 7,
|
||||||
|
tokens: { input: 10, output: 20 },
|
||||||
|
share: { url: 'https://share.example/ses_1' },
|
||||||
|
metadata: { openchamber: { kind: 'review', originalSessionID: 'ses_original' } },
|
||||||
|
project: { id: 'proj_1', worktree: '/repo/app' },
|
||||||
|
summary: { additions: 5, deletions: 3, files: 2 },
|
||||||
|
revert: { messageID: 'msg_1', partID: 'part_1' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes session list responses without sanitizing session detail responses', async () => {
|
||||||
|
let seenListQuery = null;
|
||||||
|
|
||||||
|
const upstream = express();
|
||||||
|
upstream.get('/session', (req, res) => {
|
||||||
|
seenListQuery = req.query;
|
||||||
|
res.json([
|
||||||
|
{
|
||||||
|
id: 'ses_1',
|
||||||
|
directory: '/repo/app',
|
||||||
|
title: 'Alpha',
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
summary: {
|
||||||
|
additions: 5,
|
||||||
|
deletions: 3,
|
||||||
|
files: 2,
|
||||||
|
diffs: [{ patch: '@@ -1 +1 @@', additions: 5, deletions: 3 }],
|
||||||
|
},
|
||||||
|
metadata: { custom: { value: 'kept' } },
|
||||||
|
revert: { messageID: 'msg_1', partID: 'part_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
upstream.get('/session/abc', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
id: 'abc',
|
||||||
|
directory: '/repo/app',
|
||||||
|
title: 'Detail',
|
||||||
|
summary: { diffs: [{ patch: '@@ -1 +1 @@' }] },
|
||||||
|
revert: { messageID: 'msg_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
upstreamServer = await listen(upstream);
|
||||||
|
const upstreamPort = upstreamServer.address().port;
|
||||||
|
const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
registerOpenCodeProxy(app, {
|
||||||
|
fs: {
|
||||||
|
promises: {
|
||||||
|
realpath: async (value) => value === '/link/repo' ? '/real/repo' : value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
os: {},
|
||||||
|
path,
|
||||||
|
OPEN_CODE_READY_GRACE_MS: 0,
|
||||||
|
getRuntime: () => ({
|
||||||
|
openCodePort: upstreamPort,
|
||||||
|
openCodeBaseUrl: externalBaseUrl,
|
||||||
|
isOpenCodeReady: true,
|
||||||
|
openCodeNotReadySince: 0,
|
||||||
|
isRestartingOpenCode: false,
|
||||||
|
}),
|
||||||
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`,
|
||||||
|
ensureOpenCodeApiPrefix: () => {},
|
||||||
|
});
|
||||||
|
proxyServer = await listen(app);
|
||||||
|
const proxyPort = proxyServer.address().port;
|
||||||
|
|
||||||
|
const listResponse = await fetch(`http://127.0.0.1:${proxyPort}/api/session?directory=%2Flink%2Frepo`);
|
||||||
|
|
||||||
|
expect(listResponse.status).toBe(200);
|
||||||
|
expect(seenListQuery).toMatchObject({ directory: '/real/repo' });
|
||||||
|
await expect(listResponse.json()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 'ses_1',
|
||||||
|
directory: '/repo/app',
|
||||||
|
title: 'Alpha',
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
summary: { additions: 5, deletions: 3, files: 2 },
|
||||||
|
metadata: { custom: { value: 'kept' } },
|
||||||
|
revert: { messageID: 'msg_1', partID: 'part_1' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const detailResponse = await fetch(`http://127.0.0.1:${proxyPort}/api/session/abc`);
|
||||||
|
|
||||||
|
expect(detailResponse.status).toBe(200);
|
||||||
|
await expect(detailResponse.json()).resolves.toEqual({
|
||||||
|
id: 'abc',
|
||||||
|
directory: '/repo/app',
|
||||||
|
title: 'Detail',
|
||||||
|
summary: { diffs: [{ patch: '@@ -1 +1 @@' }] },
|
||||||
|
revert: { messageID: 'msg_1', snapshot: 'abc123', diff: 'diff --git a/x b/x' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards unparsed SDK JSON bodies to generic API proxy requests', async () => {
|
it('forwards unparsed SDK JSON bodies to generic API proxy requests', async () => {
|
||||||
const upstream = express();
|
const upstream = express();
|
||||||
upstream.post('/session/abc/revert', express.json(), (req, res) => {
|
upstream.post('/session/abc/revert', express.json(), (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user