From d3fd2639b18c1d6f4096427aab7507e723127e6f Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Fri, 14 Aug 2026 17:59:26 +0900 Subject: [PATCH] fix(sessions): respect transcript clear boundaries --- .../list/claude/claude-sessions.provider.ts | 50 +++++++++- .../list/gjc/gjc-sessions.provider.ts | 24 +++++ .../providers/tests/claude-sessions.test.ts | 91 +++++++++++++++++++ .../providers/tests/gjc-sessions.test.ts | 63 +++++++++++++ server/shared/types.ts | 6 ++ src/stores/useSessionStore.test.ts | 50 ++++++++++ src/stores/useSessionStore.ts | 45 +++++++++ 7 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 server/modules/providers/tests/claude-sessions.test.ts diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 8cdf44f..0724977 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -23,6 +23,7 @@ type ClaudeHistoryResult = messages?: AnyRecord[]; total?: number; hasMore?: boolean; + historyEpoch?: string | null; }; type ClaudeHistoryMessagesResult = @@ -30,10 +31,11 @@ type ClaudeHistoryMessagesResult = | { messages: AnyRecord[]; total: number; - hasMore: boolean; - offset?: number; - limit?: number | null; - }; + hasMore: boolean; + offset?: number; + limit?: number | null; + historyEpoch?: string | null; + }; async function parseAgentTools(filePath: string): Promise { const tools: AnyRecord[] = []; @@ -121,6 +123,7 @@ async function getSessionMessages( const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-')); const messages: AnyRecord[] = []; + let historyEpoch: string | null = null; const agentToolsCache = new Map(); const fileStream = fs.createReadStream(jsonLPath); @@ -137,6 +140,11 @@ async function getSessionMessages( try { const entry = JSON.parse(line) as AnyRecord; if (entry.sessionId === providerSessionId) { + if (isClaudeContextClearEntry(entry)) { + messages.length = 0; + historyEpoch = getClaudeHistoryEpoch(entry); + continue; + } messages.push(entry); } } catch { @@ -181,7 +189,14 @@ async function getSessionMessages( const total = sortedMessages.length; if (limit === null) { - return sortedMessages; + return { + messages: sortedMessages, + total, + hasMore: false, + offset: 0, + limit: null, + historyEpoch, + }; } const startIndex = Math.max(0, total - offset - limit); @@ -195,6 +210,7 @@ async function getSessionMessages( hasMore, offset, limit, + historyEpoch, }; } catch (error) { console.error(`Error reading messages for session ${sessionId}:`, error); @@ -261,6 +277,28 @@ function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | }; } +/** + * Claude Code keeps the same provider session id after `/clear`. The durable + * transcript marks the action with its tagged local-command record, which is + * the authoritative boundary between the discarded and current contexts. + */ +function isClaudeContextClearEntry(entry: AnyRecord): boolean { + if (entry.message?.role !== 'user' || typeof entry.message.content !== 'string') { + return false; + } + const payload = parseLocalCommandPayload(entry.message.content); + return payload?.commandName.trim().toLowerCase() === '/clear'; +} + +function getClaudeHistoryEpoch(entry: AnyRecord): string { + const boundaryId = typeof entry.uuid === 'string' && entry.uuid + ? entry.uuid + : (typeof entry.timestamp === 'string' && entry.timestamp + ? entry.timestamp + : 'context-clear'); + return `claude:${boundaryId}`; +} + /** * Produces the short user-visible command string that should appear in chat. * @@ -600,6 +638,7 @@ export class ClaudeSessionsProvider implements IProviderSessions { } const rawMessages = Array.isArray(result) ? result : (result.messages || []); + const historyEpoch = Array.isArray(result) ? null : (result.historyEpoch ?? null); const toolResultMap = new Map(); for (const raw of rawMessages) { @@ -656,6 +695,7 @@ export class ClaudeSessionsProvider implements IProviderSessions { hasMore, offset: normalizedOffset, limit: normalizedLimit, + historyEpoch, }; } } diff --git a/server/modules/providers/list/gjc/gjc-sessions.provider.ts b/server/modules/providers/list/gjc/gjc-sessions.provider.ts index c76eebd..0c7287e 100644 --- a/server/modules/providers/list/gjc/gjc-sessions.provider.ts +++ b/server/modules/providers/list/gjc/gjc-sessions.provider.ts @@ -65,6 +65,13 @@ class NormalizedMessageRingBuffer { } } + reset(): void { + this.entries = []; + this.startIndex = 0; + this.bufferedBytes = 0; + this.truncated = false; + } + get messages(): NormalizedMessage[] { const messages: NormalizedMessage[] = []; for (let index = this.startIndex; index < this.entries.length; index += 1) { @@ -192,6 +199,7 @@ async function streamPiSessionMessages( provider: PiTranscriptProvider, sessionId: string, onMessage: (message: AnyRecord) => void, + onHistoryReset: (historyEpoch: string) => void, ): Promise { try { const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path; @@ -201,13 +209,24 @@ async function streamPiSessionMessages( return; } + let lineNumber = 0; for await (const line of readBoundedJsonlLines(sessionFilePath)) { + lineNumber += 1; if (!line.trim()) { continue; } try { const entry = JSON.parse(line) as AnyRecord; + if (entry.type === 'custom' && entry.customType === 'context_clear') { + const boundaryId = typeof entry.id === 'string' && entry.id + ? entry.id + : (typeof entry.timestamp === 'string' && entry.timestamp + ? entry.timestamp + : String(lineNumber)); + onHistoryReset(`${provider}:${boundaryId}`); + continue; + } if (entry.type !== 'message') { continue; } @@ -457,12 +476,16 @@ export class GjcSessionsProvider implements IProviderSessions { getHistoryBufferRecordLimit(normalizedLimit, normalizedOffset), MAX_BUFFERED_HISTORY_BYTES, ); + let historyEpoch: string | null = null; try { await streamPiSessionMessages(this.provider, sessionId, (rawMessage) => { for (const message of this.normalizeHistoryEntry(rawMessage, sessionId)) { messageBuffer.push(message); } + }, (nextHistoryEpoch) => { + messageBuffer.reset(); + historyEpoch = nextHistoryEpoch; }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -511,6 +534,7 @@ export class GjcSessionsProvider implements IProviderSessions { hasMore: pageHasMore || messageBuffer.truncated, offset: normalizedOffset, limit: normalizedLimit, + historyEpoch, tokenUsage: null, }; } diff --git a/server/modules/providers/tests/claude-sessions.test.ts b/server/modules/providers/tests/claude-sessions.test.ts new file mode 100644 index 0000000..c9c910b --- /dev/null +++ b/server/modules/providers/tests/claude-sessions.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; +import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js'; + +async function withIsolatedDatabase(runTest: () => void | Promise): Promise { + const previousDatabasePath = process.env.DATABASE_PATH; + const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-provider-db-')); + + closeConnection(); + process.env.DATABASE_PATH = path.join(tempDirectory, 'auth.db'); + await initializeDatabase(); + + try { + await runTest(); + } finally { + closeConnection(); + if (previousDatabasePath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = previousDatabasePath; + } + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +test('claude history starts after the latest tagged /clear command', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-session-clear-history-')); + const workspacePath = path.join(tempRoot, 'workspace'); + const sessionId = 'claude-clear-history'; + const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`); + const clearCommand = '/clear\nclear\n'; + const rows = [ + { type: 'user', uuid: 'old-user', timestamp: '2026-07-09T00:00:01.000Z', sessionId, message: { role: 'user', content: [{ type: 'text', text: 'Old question' }] } }, + // A slightly later timestamp before the boundary proves file order, not + // clock order, owns `/clear` segmentation. + { type: 'assistant', uuid: 'old-assistant', timestamp: '2026-07-09T00:00:03.100Z', sessionId, message: { role: 'assistant', content: [{ type: 'text', text: 'Old answer' }] } }, + { type: 'user', uuid: 'clear-one', timestamp: '2026-07-09T00:00:03.000Z', sessionId, userType: 'external', message: { role: 'user', content: clearCommand } }, + { type: 'user', uuid: 'middle-user', timestamp: '2026-07-09T00:00:04.000Z', sessionId, message: { role: 'user', content: [{ type: 'text', text: 'Middle question' }] } }, + { type: 'user', uuid: 'clear-two', timestamp: '2026-07-09T00:00:05.000Z', sessionId, userType: 'external', message: { role: 'user', content: clearCommand } }, + { type: 'user', uuid: 'new-user', timestamp: '2026-07-09T00:00:06.000Z', sessionId, message: { role: 'user', content: [{ type: 'text', text: 'Explain /clear without running it' }] } }, + { type: 'assistant', uuid: 'new-assistant', timestamp: '2026-07-09T00:00:07.000Z', sessionId, message: { role: 'assistant', content: [{ type: 'text', text: 'New answer' }] } }, + ]; + await writeFile(transcriptPath, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`, 'utf8'); + + try { + await withIsolatedDatabase(async () => { + sessionsDb.createSession( + sessionId, + 'claude', + workspacePath, + undefined, + undefined, + undefined, + transcriptPath, + ); + const provider = new ClaudeSessionsProvider(); + + const newest = await provider.fetchHistory(sessionId, { limit: 1 }); + assert.equal(newest.historyEpoch, 'claude:clear-two'); + assert.equal(newest.total, 2); + assert.equal(newest.hasMore, true); + assert.deepEqual(newest.messages.map(message => message.content), ['New answer']); + + const older = await provider.fetchHistory(sessionId, { limit: 1, offset: 1 }); + assert.equal(older.historyEpoch, 'claude:clear-two'); + assert.equal(older.hasMore, false); + assert.deepEqual(older.messages.map(message => message.content), ['Explain /clear without running it']); + + await appendFile(transcriptPath, `${JSON.stringify({ + type: 'user', + uuid: 'clear-three', + timestamp: '2026-07-09T00:00:08.000Z', + sessionId, + userType: 'external', + message: { role: 'user', content: clearCommand }, + })}\n`, 'utf8'); + const empty = await provider.fetchHistory(sessionId); + assert.equal(empty.historyEpoch, 'claude:clear-three'); + assert.equal(empty.total, 0); + assert.equal(empty.hasMore, false); + assert.deepEqual(empty.messages, []); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/server/modules/providers/tests/gjc-sessions.test.ts b/server/modules/providers/tests/gjc-sessions.test.ts index 62ed613..7da9e4a 100644 --- a/server/modules/providers/tests/gjc-sessions.test.ts +++ b/server/modules/providers/tests/gjc-sessions.test.ts @@ -367,6 +367,69 @@ test('gjc sessions provider excludes hidden and internal-role messages from hist } }); +test('gjc history starts after the latest context_clear boundary', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'gjc-session-clear-history-')); + const workspacePath = path.join(tempRoot, 'workspace'); + const sessionsDir = path.join(tempRoot, '.gjc', 'agent', 'sessions', '-workspace'); + await Promise.all([ + mkdir(workspacePath, { recursive: true }), + mkdir(sessionsDir, { recursive: true }), + ]); + + try { + const transcriptPath = path.join(sessionsDir, '2026-07-09T00-00-00_gjc-clear-history.jsonl'); + const lines = [ + { type: 'session', version: 3, id: 'gjc-clear-history', timestamp: '2026-07-09T00:00:00.000Z', cwd: workspacePath }, + { type: 'message', id: 'old-user', timestamp: '2026-07-09T00:00:01.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Old question' }] } }, + { type: 'custom', customType: 'context_clear', id: 'clear-one', timestamp: '2026-07-09T00:00:02.000Z', data: { sessionId: 'gjc-clear-history' } }, + { type: 'message', id: 'middle-user', timestamp: '2026-07-09T00:00:03.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Middle question' }] } }, + { type: 'custom', customType: 'context_clear', id: 'clear-two', timestamp: '2026-07-09T00:00:04.000Z', data: { sessionId: 'gjc-clear-history' } }, + { type: 'message', id: 'new-user', timestamp: '2026-07-09T00:00:05.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Explain /clear without running it' }] } }, + { type: 'message', id: 'new-assistant', timestamp: '2026-07-09T00:00:06.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'New answer' }] } }, + ]; + await writeFile(transcriptPath, `${lines.map(line => JSON.stringify(line)).join('\n')}\n`, 'utf8'); + + await withIsolatedDatabase(async () => { + sessionsDb.createSession( + 'gjc-clear-history', + 'gjc', + workspacePath, + undefined, + undefined, + undefined, + transcriptPath, + ); + const provider = new GjcSessionsProvider(); + + const newest = await provider.fetchHistory('gjc-clear-history', { limit: 1 }); + assert.equal(newest.historyEpoch, 'gjc:clear-two'); + assert.equal(newest.total, 2); + assert.equal(newest.hasMore, true); + assert.deepEqual(newest.messages.map(message => message.content), ['New answer']); + + const older = await provider.fetchHistory('gjc-clear-history', { limit: 1, offset: 1 }); + assert.equal(older.historyEpoch, 'gjc:clear-two'); + assert.equal(older.hasMore, false); + assert.deepEqual(older.messages.map(message => message.content), ['Explain /clear without running it']); + + await appendFile(transcriptPath, `${JSON.stringify({ + type: 'custom', + customType: 'context_clear', + id: 'clear-three', + timestamp: '2026-07-09T00:00:07.000Z', + data: { sessionId: 'gjc-clear-history' }, + })}\n`, 'utf8'); + const empty = await provider.fetchHistory('gjc-clear-history'); + assert.equal(empty.historyEpoch, 'gjc:clear-three'); + assert.equal(empty.total, 0); + assert.equal(empty.hasMore, false); + assert.deepEqual(empty.messages, []); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('gjc sessions provider returns a folded tool call for the newest one-message page', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'gjc-session-tail-history-')); const workspacePath = path.join(tempRoot, 'workspace'); diff --git a/server/shared/types.ts b/server/shared/types.ts index ee8f6ee..d95074f 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -315,6 +315,12 @@ export type FetchHistoryResult = { hasMore: boolean; offset: number; limit: number | null; + /** + * Stable provider-native boundary for the currently visible history segment. + * Providers that preserve a session id across `/clear` change this value so + * clients can discard cached messages from the preceding context. + */ + historyEpoch?: string | null; tokenUsage?: unknown; sourceStatus?: 'available' | 'missing' | 'unreadable'; }; diff --git a/src/stores/useSessionStore.test.ts b/src/stores/useSessionStore.test.ts index 2a907c6..d62f2d3 100644 --- a/src/stores/useSessionStore.test.ts +++ b/src/stores/useSessionStore.test.ts @@ -181,6 +181,56 @@ test('refresh preserves the message window expanded by pagination', async () => } }); +test('a changed history epoch drops cached rows from the preceding context', async () => { + const originalFetch = globalThis.fetch; + const pending: PendingRequest[] = []; + globalThis.fetch = ((url: string) => new Promise((resolve) => { + pending.push({ url, resolve }); + })) as typeof fetch; + + try { + const store = createStore(); + const initial = store.fetchFromServer('session', { limit: 20 }); + pending.shift()!.resolve(response({ + messages: [ + { id: 'old-server', sessionId: 'session', timestamp: '2026-01-01T00:00:00Z', kind: 'text', provider: 'gjc' }, + ], + total: 1, + hasMore: false, + historyEpoch: null, + })); + await initial; + + store.appendRealtime('session', { + id: 'old-realtime', + sessionId: 'session', + timestamp: '2026-01-01T00:00:01Z', + kind: 'text', + provider: 'gjc', + role: 'assistant', + content: 'Old live response', + }); + + const refresh = store.refreshFromServer('session'); + pending.shift()!.resolve(response({ + messages: [ + { id: 'new-server', sessionId: 'session', timestamp: '2026-01-01T00:00:02Z', kind: 'text', provider: 'gjc' }, + ], + total: 1, + hasMore: false, + historyEpoch: 'gjc:clear-one', + })); + const slot = await refresh; + + assert.equal(slot.historyEpoch, 'gjc:clear-one'); + assert.deepEqual(slot.serverMessages.map(message => message.id), ['new-server']); + assert.deepEqual(slot.realtimeMessages, []); + assert.deepEqual(slot.merged.map(message => message.id), ['new-server']); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('refresh queues a reconcile after an explicit pagination request settles', async () => { const originalFetch = globalThis.fetch; const pending: PendingRequest[] = []; diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 3df51d9..f4b7fa2 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -118,11 +118,14 @@ export interface SessionSlot { _loadingTicket: number | null; /** Whether subsequent pages/reconciles should request image attachment data. */ _includeImages: boolean; + /** @internal Whether a provider history epoch has been observed for this slot. */ + _historyEpochKnown: boolean; status: SessionStatus; fetchedAt: number; total: number; hasMore: boolean; offset: number; + historyEpoch: string | null; tokenUsage: unknown; } @@ -147,9 +150,33 @@ function createEmptySlot(): SessionSlot { _reconcilePending: false, _loadingTicket: null, _includeImages: true, + _historyEpochKnown: false, + historyEpoch: null, }; } +/** + * Records a provider-native `/clear` boundary. The first observed epoch merely + * initializes the slot; later changes mean every cached/realtime row belongs + * to an earlier context and must not survive the server window replacement. + */ +function applyHistoryEpoch(slot: SessionSlot, data: Record): boolean { + if (!Object.prototype.hasOwnProperty.call(data, 'historyEpoch')) { + return false; + } + + const nextEpoch = typeof data.historyEpoch === 'string' ? data.historyEpoch : null; + const changed = slot._historyEpochKnown && slot.historyEpoch !== nextEpoch; + slot._historyEpochKnown = true; + slot.historyEpoch = nextEpoch; + + if (changed) { + slot.realtimeMessages = EMPTY; + slot.tokenUsage = null; + } + return changed; +} + function getRealtimeMessageIdentity(message: NormalizedMessage): string | null { if (message.id) { return `id:${message.id}`; @@ -661,6 +688,7 @@ export function useSessionStore() { return slot; } + applyHistoryEpoch(slot, data); slot.serverMessages = dedupeMessagesById(messages); slot.total = data.total ?? messages.length; slot.hasMore = Boolean(data.hasMore); @@ -752,6 +780,22 @@ export function useSessionStore() { return slot; } + if (applyHistoryEpoch(slot, data)) { + // This page used an offset from the preceding context and cannot be + // merged into the new one. Clear it immediately and queue a fresh + // offset-zero reconcile after the in-flight page releases the slot. + slot.serverMessages = EMPTY; + slot.total = data.total ?? 0; + slot.hasMore = false; + slot.offset = 0; + recomputeMergedIfNeeded(slot); + notify(sessionId); + if (refreshFromServerRef.current) { + void refreshFromServerRef.current(sessionId); + } + return slot; + } + slot.serverMessages = dedupeMessagesById([ ...olderMessages, ...slot.serverMessages, @@ -894,6 +938,7 @@ export function useSessionStore() { } const messages: NormalizedMessage[] = data.messages || []; + applyHistoryEpoch(slot, data); slot.serverMessages = dedupeMessagesById(messages); slot.total = data.total ?? messages.length; slot.hasMore = Boolean(data.hasMore);