From 968563043647c61868112bac53bfc51193f4af04 Mon Sep 17 00:00:00 2001 From: Hristo Karamanliev Date: Thu, 11 Jun 2026 23:06:19 +0300 Subject: [PATCH] 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 --- packages/ui/src/stores/globalSessions.test.ts | 58 ++++++ packages/ui/src/stores/globalSessions.ts | 4 +- packages/ui/src/sync/sanitize.test.ts | 154 ++++++++++++++ packages/ui/src/sync/sanitize.ts | 159 ++++++++++++--- packages/ui/src/sync/use-sync.test.ts | 29 +++ packages/ui/src/sync/use-sync.ts | 19 +- packages/web/server/lib/opencode/proxy.js | 124 ++++++++++- packages/web/server/opencode-proxy.test.js | 193 ++++++++++++++++++ 8 files changed, 701 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/stores/globalSessions.test.ts create mode 100644 packages/ui/src/sync/sanitize.test.ts create mode 100644 packages/ui/src/sync/use-sync.test.ts diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts new file mode 100644 index 00000000..9a164e41 --- /dev/null +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -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 }) + }) +}) diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 7a01905b..a7da6e67 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -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; diff --git a/packages/ui/src/sync/sanitize.test.ts b/packages/ui/src/sync/sanitize.test.ts new file mode 100644 index 00000000..03174105 --- /dev/null +++ b/packages/ui/src/sync/sanitize.test.ts @@ -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) + }) +}) diff --git a/packages/ui/src/sync/sanitize.ts b/packages/ui/src/sync/sanitize.ts index 3df7fb67..a94554aa 100644 --- a/packages/ui/src/sync/sanitize.ts +++ b/packages/ui/src/sync/sanitize.ts @@ -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 | undefined => { + if (!revert || typeof revert !== "object" || Array.isArray(revert)) { + return undefined + } + + const marker: Pick = {} + 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 = (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 = { ...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 diff --git a/packages/ui/src/sync/use-sync.test.ts b/packages/ui/src/sync/use-sync.test.ts new file mode 100644 index 00000000..bdd7dade --- /dev/null +++ b/packages/ui/src/sync/use-sync.test.ts @@ -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) + }) +}) diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index a32320fe..4e11beb9 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -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 }) } diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index b66bff59..47eb6c98 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -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) => { const { 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 app.use('/api', (_req, _res, next) => { ensureOpenCodeApiPrefix(); @@ -453,7 +567,7 @@ export const registerOpenCodeProxy = (app, deps) => { return bTime - aTime; }); console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); - return res.json(merged); + return res.json(sanitizeSessionListPayload(merged)); } catch (error) { console.log(`[SessionMerge] Error: ${error.message}, falling through`); 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/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. const apiProxy = createProxyMiddleware({ target: resolveProxyTarget(), diff --git a/packages/web/server/opencode-proxy.test.js b/packages/web/server/opencode-proxy.test.js index f9c9d364..f5dfc852 100644 --- a/packages/web/server/opencode-proxy.test.js +++ b/packages/web/server/opencode-proxy.test.js @@ -237,6 +237,199 @@ describe('OpenCode proxy SSE forwarding', () => { 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 () => { const upstream = express(); upstream.post('/session/abc/revert', express.json(), (req, res) => {