From 3267e72881b16bd177b6f439eb098a9fa924a3ea Mon Sep 17 00:00:00 2001 From: Rustam Date: Fri, 18 Sep 2026 10:32:35 +0300 Subject: [PATCH] fix: support current ChatGPT conversation responses Handle the plural conversations endpoint and its flat messages payload while preserving legacy mapping support. --- README.md | 2 +- extension/src/page/page-script.ts | 99 ++++++++++---- extension/src/shared/trimmer.ts | 127 +++++++++++++++++- .../unit/page-script-bootstrap-dedupe.test.ts | 1 + tests/unit/page-script-bootstrap.test.ts | 1 + tests/unit/page-script.test.ts | 74 +++++++++- tests/unit/trimmer.test.ts | 62 +++++++++ 7 files changed, 332 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 746465d..6a03ca4 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ LightSession uses a **Fetch Proxy** architecture: 1. **Injection** – at `document_start`, injects a script into the page context before ChatGPT loads. 2. **Interception** – patches `window.fetch` to intercept `/backend-api/` JSON responses. -3. **Trimming** – parses the conversation mapping, counts messages (role transitions), keeps the last N messages. +3. **Trimming** – parses either the legacy conversation mapping or the current flat message list, counts role transitions, and keeps the last N messages. 4. **Response** – if there are more messages than the limit, returns a modified Response with trimmed JSON; otherwise passes the original response through unchanged. **Message counting**: A "message" is a contiguous sequence of nodes from the same role. Consecutive assistant nodes (e.g., from Extended Thinking) are aggregated as ONE message. diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index f68f7a2..23e1738 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -16,6 +16,7 @@ export {}; import { trimMapping, + trimMessages, type ConversationData, } from '../shared/trimmer'; import { TIMING } from '../shared/constants'; @@ -153,18 +154,75 @@ function dispatchStatus(status: TrimStatus): void { function extractConversationRequestId(url: URL): string | null { const match = url.pathname.match( - /^\/backend-api\/(?:conversation|shared_conversation)\/([^/]+)\/?$/ + /^\/backend-api\/(?:conversation|conversations|shared_conversation)\/([^/]+)\/?$/ ); return match?.[1] ?? null; } -function looksLikeConversationData( - json: ConversationData | null -): json is ConversationData & { - mapping: NonNullable; - current_node: string; -} { - return !!json && typeof json === 'object' && !!json.mapping && typeof json.current_node === 'string'; +function looksLikeConversationData(json: ConversationData | null): json is ConversationData { + if (!json || typeof json !== 'object') { + return false; + } + + return (!!json.mapping && typeof json.current_node === 'string') || Array.isArray(json.messages); +} + +interface TrimmedConversation { + data: ConversationData; + keptCount: number; + totalCount: number; + visibleKept: number; + visibleTotal: number; +} + +function trimConversationData(data: ConversationData, limit: number): TrimmedConversation | null { + if (Array.isArray(data.messages)) { + const trimmed = trimMessages(data, limit); + if (!trimmed) { + return null; + } + + const firstMessageId = trimmed.messages[0]?.id; + const lastMessageId = trimmed.messages[trimmed.messages.length - 1]?.id; + const pageInfo = data.page_info; + + return { + ...trimmed, + data: { + ...data, + messages: trimmed.messages, + ...(pageInfo + ? { + page_info: { + ...pageInfo, + ...(firstMessageId ? { start_cursor: firstMessageId } : {}), + ...(lastMessageId ? { end_cursor: lastMessageId } : {}), + // The retained suffix is intentionally the complete visible + // history for this session; don't invite the UI to page older + // records back into the DOM. + has_previous_page: false, + }, + } + : {}), + }, + }; + } + + const trimmed = trimMapping(data, limit); + if (!trimmed) { + return null; + } + + return { + ...trimmed, + data: { + ...data, + mapping: trimmed.mapping, + current_node: trimmed.current_node, + // ChatGPT needs the legacy root to know where to start rendering. + root: trimmed.root, + }, + }; } async function attemptAuthoritativeConversationSync(conversationId: string): Promise { @@ -224,7 +282,7 @@ function getConfig(): LsConfig { window.__LS_CONFIG__ = stored; return stored; } - + // Fall back to window config (set by content script events) const cfg = window.__LS_CONFIG__; if (cfg) { @@ -235,7 +293,7 @@ function getConfig(): LsConfig { debug: cfg.debug ?? DEFAULT_CONFIG.debug, }; } - + return DEFAULT_CONFIG; } @@ -248,19 +306,20 @@ function isConversationRequest(method: string, url: URL): boolean { return false; } - // Only endpoints that return the conversation tree we can trim. + // Only endpoints that return conversation history we can trim. // ChatGPT performs many GET /backend-api/* requests on load (/me, /models, /settings, etc.). // Intercepting those adds unnecessary overhead (clone/json) and config gating delay. // // Allowed: // - /backend-api/conversation/ + // - /backend-api/conversations/ (current paginated response) // - /backend-api/shared_conversation/ (share links) // // Explicitly excluded by pattern (extra path segments): // - /backend-api/conversation//stream_status // - /backend-api/conversation//textdocs const path = url.pathname; - return /^\/backend-api\/(conversation|shared_conversation)\/[^/]+\/?$/.test(path); + return /^\/backend-api\/(conversation|conversations|shared_conversation)\/[^/]+\/?$/.test(path); } /** @@ -383,8 +442,8 @@ async function interceptedFetch( completedBootstrapSyncIds.add(requestConversationId); } - // Trim the mapping - const trimmed = trimMapping(json, cfg.limit); + // Trim the legacy mapping or the current flat messages response. + const trimmed = trimConversationData(json, cfg.limit); if (!trimmed) { return res; @@ -423,17 +482,7 @@ async function interceptedFetch( limit: cfg.limit, }); - // Build modified response data - const modifiedData: ConversationData = { - ...json, - mapping: trimmed.mapping, - current_node: trimmed.current_node, - }; - - // Always set root - ChatGPT needs this to know where to start rendering - modifiedData.root = trimmed.root; - - return createModifiedResponse(res, modifiedData); + return createModifiedResponse(res, trimmed.data); } catch (error) { // On any error, return original response log('Error in fetch interceptor:', error); diff --git a/extension/src/shared/trimmer.ts b/extension/src/shared/trimmer.ts index b0fc6b2..da66e8d 100644 --- a/extension/src/shared/trimmer.ts +++ b/extension/src/shared/trimmer.ts @@ -15,6 +15,23 @@ export interface ChatMessage { }; } +/** + * Current conversation responses use a flat, chronological messages array + * rather than the legacy mapping tree. Keep this deliberately permissive: + * ChatGPT adds message metadata frequently, and trimming must preserve it. + */ +export interface ConversationMessage extends ChatMessage { + id?: string; + [key: string]: unknown; +} + +export interface ConversationPageInfo { + start_cursor?: string; + end_cursor?: string; + has_previous_page?: boolean; + [key: string]: unknown; +} + export interface ChatNode { parent: string | null; children?: string[]; @@ -29,6 +46,8 @@ export interface ConversationData { mapping?: ChatMapping; current_node?: string; root?: string; + messages?: ConversationMessage[]; + page_info?: ConversationPageInfo; } export interface TrimResult { @@ -41,6 +60,14 @@ export interface TrimResult { visibleTotal: number; } +export interface MessagesTrimResult { + messages: ConversationMessage[]; + keptCount: number; + totalCount: number; + visibleKept: number; + visibleTotal: number; +} + // ============================================================================ // Constants // ============================================================================ @@ -65,11 +92,19 @@ export const HIDDEN_ROLES = new Set([ * Nodes without messages (like root nodes) are not visible. */ export function isVisibleMessage(node: ChatNode): boolean { - const role = node.message?.author?.role; - // Must have a role to be considered a message - if (!role) return false; - // Exclude hidden/internal roles - return !HIDDEN_ROLES.has(role); + return isVisibleRole(node.message?.author?.role); +} + +/** + * Check whether an item in the modern flat messages array is visible. + */ +export function isVisibleConversationMessage(message: ConversationMessage): boolean { + return isVisibleRole(message.author?.role); +} + +function isVisibleRole(role: string | undefined): boolean { + // Must have a role to be considered a message. + return !!role && !HIDDEN_ROLES.has(role); } // ============================================================================ @@ -251,3 +286,85 @@ export function trimMapping( visibleTotal, }; } + +/** + * Trim a modern flat conversation response to its last N visible turns. + * + * The current /backend-api/conversations/ response contains a + * chronological messages array instead of the legacy mapping tree. We retain + * the same role-transition semantics as trimMapping() and preserve hidden + * records that occur within the retained suffix (for example tool output + * between a user prompt and its final assistant answer). + */ +export function trimMessages(data: ConversationData, limit: number): MessagesTrimResult | null { + const messages = data.messages; + if (!Array.isArray(messages)) { + return null; + } + + const effectiveLimit = Math.max(1, limit); + let visibleTotal = 0; + let lastVisibleRole: string | null = null; + + for (const message of messages) { + if (!isVisibleConversationMessage(message)) { + continue; + } + + const role = message.author?.role ?? ''; + if (role !== lastVisibleRole) { + visibleTotal++; + lastVisibleRole = role; + } + } + + if (visibleTotal === 0) { + return null; + } + + let turnCount = 0; + let cutIndex = 0; + let lastRole: string | null = null; + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!message || !isVisibleConversationMessage(message)) { + continue; + } + + const role = message.author?.role ?? ''; + if (role !== lastRole) { + turnCount++; + lastRole = role; + } + + if (turnCount > effectiveLimit) { + cutIndex = i + 1; + break; + } + } + + const keptMessages = messages.slice(cutIndex); + let visibleKept = 0; + let lastKeptRole: string | null = null; + + for (const message of keptMessages) { + if (!isVisibleConversationMessage(message)) { + continue; + } + + const role = message.author?.role ?? ''; + if (role !== lastKeptRole) { + visibleKept++; + lastKeptRole = role; + } + } + + return { + messages: keptMessages, + keptCount: keptMessages.length, + totalCount: messages.length, + visibleKept, + visibleTotal, + }; +} diff --git a/tests/unit/page-script-bootstrap-dedupe.test.ts b/tests/unit/page-script-bootstrap-dedupe.test.ts index a19c44b..21fd45d 100644 --- a/tests/unit/page-script-bootstrap-dedupe.test.ts +++ b/tests/unit/page-script-bootstrap-dedupe.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../../extension/src/shared/trimmer', () => ({ trimMapping: vi.fn(), + trimMessages: vi.fn(), })); import { trimMapping } from '../../extension/src/shared/trimmer'; diff --git a/tests/unit/page-script-bootstrap.test.ts b/tests/unit/page-script-bootstrap.test.ts index 13c6380..8398f70 100644 --- a/tests/unit/page-script-bootstrap.test.ts +++ b/tests/unit/page-script-bootstrap.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../../extension/src/shared/trimmer', () => ({ trimMapping: vi.fn(), + trimMessages: vi.fn(), })); import { trimMapping } from '../../extension/src/shared/trimmer'; diff --git a/tests/unit/page-script.test.ts b/tests/unit/page-script.test.ts index 1e13c78..3dd59e2 100644 --- a/tests/unit/page-script.test.ts +++ b/tests/unit/page-script.test.ts @@ -7,12 +7,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -// Mock trimMapping module before importing page-script internals +// Mock trimmer module before importing page-script internals vi.mock('../../extension/src/shared/trimmer', () => ({ trimMapping: vi.fn(), + trimMessages: vi.fn(), })); -import { trimMapping } from '../../extension/src/shared/trimmer'; +import { trimMapping, trimMessages } from '../../extension/src/shared/trimmer'; import { clearProxyReady, hasProxyReadyMarker } from '../../extension/src/shared/proxy-ready'; // ============================================================================ @@ -84,12 +85,13 @@ describe('isConversationRequest logic', () => { // Testing the logic that would be in isConversationRequest const isConversationRequest = (method: string, pathname: string): boolean => { if (method !== 'GET') return false; - return /^\/backend-api\/(conversation|shared_conversation)\/[^/]+\/?$/.test(pathname); + return /^\/backend-api\/(conversation|conversations|shared_conversation)\/[^/]+\/?$/.test(pathname); }; it('returns true only for conversation endpoints', () => { expect(isConversationRequest('GET', '/backend-api/conversation/123')).toBe(true); expect(isConversationRequest('GET', '/backend-api/conversation/123/')).toBe(true); + expect(isConversationRequest('GET', '/backend-api/conversations/current-chat')).toBe(true); expect(isConversationRequest('GET', '/backend-api/shared_conversation/abc-xyz')).toBe(true); expect( isConversationRequest( @@ -113,6 +115,7 @@ describe('isConversationRequest logic', () => { expect(isConversationRequest('GET', '/backend-api/models')).toBe(false); expect(isConversationRequest('GET', '/backend-api/conversation/123/stream_status')).toBe(false); expect(isConversationRequest('GET', '/backend-api/conversation/123/textdocs')).toBe(false); + expect(isConversationRequest('GET', '/backend-api/conversations/123/messages')).toBe(false); }); it('returns false for non-backend-api paths', () => { @@ -263,6 +266,71 @@ describe('fetch interception with trimMapping', () => { }); }); +describe('fetch interception with flat messages', () => { + const mockedTrimMessages = vi.mocked(trimMessages); + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + clearProxyReady(); + delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; + delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; + delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + delete (window as unknown as { __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean }).__LS_BOOTSTRAP_SYNC_LISTENER__; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('trims the current plural conversations endpoint and keeps page metadata coherent', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 2, debug: false })); + + const conversationData = { + current_node: 'assistant-2', + messages: [ + { id: 'user-1', author: { role: 'user' } }, + { id: 'assistant-1', author: { role: 'assistant' } }, + { id: 'user-2', author: { role: 'user' } }, + { id: 'assistant-2', author: { role: 'assistant' } }, + ], + page_info: { + start_cursor: 'user-1', + end_cursor: 'assistant-2', + has_previous_page: true, + }, + }; + const nativeFetch = vi.fn(async () => createMockResponse(conversationData)); + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + mockedTrimMessages.mockReturnValue({ + messages: conversationData.messages.slice(2), + keptCount: 2, + totalCount: 4, + visibleKept: 2, + visibleTotal: 4, + }); + + await import('../../extension/src/page/page-script'); + const response = await window.fetch( + 'https://chatgpt.com/backend-api/conversations/current-chat?num_turns=10' + ); + const body = await response.json(); + + expect(mockedTrimMessages).toHaveBeenCalledWith(conversationData, 2); + expect(body.messages.map((entry: { id: string }) => entry.id)).toEqual([ + 'user-2', + 'assistant-2', + ]); + expect(body.page_info).toMatchObject({ + start_cursor: 'user-2', + end_cursor: 'assistant-2', + has_previous_page: false, + }); + }); +}); + // ============================================================================ // Config Tests // ============================================================================ diff --git a/tests/unit/trimmer.test.ts b/tests/unit/trimmer.test.ts index 5b701ad..d877e9c 100644 --- a/tests/unit/trimmer.test.ts +++ b/tests/unit/trimmer.test.ts @@ -8,10 +8,13 @@ import { describe, it, expect } from 'vitest'; import { trimMapping, + trimMessages, isVisibleMessage, + isVisibleConversationMessage, HIDDEN_ROLES, type ChatNode, type ChatMapping, + type ConversationMessage, type ConversationData, } from '../../extension/src/shared/trimmer'; @@ -107,6 +110,16 @@ describe('isVisibleMessage', () => { }); }); +describe('isVisibleConversationMessage', () => { + it('uses the same visible-role rules for flat messages', () => { + const user: ConversationMessage = { id: 'user', author: { role: 'user' } }; + const tool: ConversationMessage = { id: 'tool', author: { role: 'tool' } }; + + expect(isVisibleConversationMessage(user)).toBe(true); + expect(isVisibleConversationMessage(tool)).toBe(false); + }); +}); + describe('HIDDEN_ROLES', () => { it('contains system, tool, and thinking', () => { expect(HIDDEN_ROLES.has('system')).toBe(true); @@ -489,3 +502,52 @@ describe('trimMapping - mapping structure', () => { expect(result!.mapping['node-1']?.message?.author?.role).toBe('assistant'); }); }); + +// ============================================================================ +// trimMessages() Tests - Current flat response shape +// ============================================================================ + +describe('trimMessages - flat conversation responses', () => { + const message = (id: string, role: string): ConversationMessage => ({ + id, + author: { role }, + content: { content_type: 'text' }, + }); + + it('keeps the last N role-transition turns and updates their count', () => { + const messages = [ + message('system-1', 'system'), + message('user-1', 'user'), + message('assistant-1', 'assistant'), + message('user-2', 'user'), + message('assistant-2', 'assistant'), + ]; + + const result = trimMessages({ messages, current_node: 'assistant-2' }, 2); + + expect(result?.messages.map((entry) => entry.id)).toEqual(['user-2', 'assistant-2']); + expect(result?.visibleTotal).toBe(4); + expect(result?.visibleKept).toBe(2); + }); + + it('retains hidden records in the kept suffix', () => { + const messages = [ + message('user-1', 'user'), + message('assistant-1', 'assistant'), + message('tool-1', 'tool'), + message('user-2', 'user'), + message('assistant-2', 'assistant'), + ]; + + const result = trimMessages({ messages, current_node: 'assistant-2' }, 2); + + expect(result?.messages.map((entry) => entry.id)).toEqual(['tool-1', 'user-2', 'assistant-2']); + expect(result?.keptCount).toBe(3); + }); + + it('returns null when there are no visible messages', () => { + expect( + trimMessages({ messages: [message('system-1', 'system'), message('tool-1', 'tool')] }, 2) + ).toBeNull(); + }); +});