Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ad59e78
feat: add conversation cache and session persistence
LifeJiggy Apr 15, 2026
fa25d90
fix: address PR review feedback
LifeJiggy Apr 16, 2026
f1655b2
test: add unit tests for conversationCache and sessionPersistence
LifeJiggy Apr 16, 2026
c9e0b60
fix: use getClaudeConfigHomeDir for consistent config path
LifeJiggy Apr 16, 2026
ebf5fad
fix: address PR #705 blockers
LifeJiggy Apr 17, 2026
d8e3a9a
fix: fully address PR #705 blockers
LifeJiggy Apr 17, 2026
62916ee
fix: address all non-blocking issues for PR #705
LifeJiggy Apr 17, 2026
ab0c64b
fix: address PR #705 remaining blocker
LifeJiggy Apr 20, 2026
0be6ee4
Merge branch 'main' of https://github.com/Gitlawb/openclaude into fea…
LifeJiggy Apr 20, 2026
1c645a5
chore: remove PR705 review comment file
LifeJiggy Apr 21, 2026
a1d53a5
fix: preserve full SDKMessage fields in cache round-trip
LifeJiggy Apr 22, 2026
c4b0ccc
fix: resolve PR 705 blocking issues
LifeJiggy Apr 24, 2026
ca463fe
fix: use temp test directory in sessionPersistence test
LifeJiggy Apr 24, 2026
e314d38
fix: resolve PR 705 remaining blockers
LifeJiggy Apr 25, 2026
27d2a20
fix: resolve PR 705 remaining blockers - fetchLatestEvents returns fr…
LifeJiggy Apr 27, 2026
8408f4d
fix: PR 705 - round-trip content type safety and pagination metadata
LifeJiggy Apr 27, 2026
21d0422
fix: PR 705 - persist pagination metadata across restarts
LifeJiggy Apr 27, 2026
a9efbb6
fix: preserve full SDKMessage shape in cache serializer
LifeJiggy May 14, 2026
b92e909
fix: add error handling to PR intent scan entry point
LifeJiggy May 14, 2026
eef13df
fix: persist all SDKMessage variant fields through cache round-trip
LifeJiggy May 18, 2026
3f11019
fix: persist pagination cursor and use uuid for cache-dirty detection…
LifeJiggy May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 107 additions & 10 deletions src/assistant/sessionHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@ import { getOauthConfig } from '../constants/oauth.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { logForDebugging } from '../utils/debug.js'
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
import {
ConversationCache,
createConversationCache,
type CacheMessage,
} from '../utils/conversationCache.js'
import {
saveSession,
loadSession,
listSessions,
createSession,
} from '../utils/sessionPersistence.js'

export const HISTORY_PAGE_SIZE = 100

// Module-level cache for session history
let historyCache: ConversationCache | undefined

function getHistoryCache(): ConversationCache {
if (!historyCache) {
historyCache = createConversationCache({
maxSize: 50,
ttlMs: 60 * 60 * 1000, // 1 hour
})
}
return historyCache
}

export type HistoryPage = {
/** Chronological order within the page. */
events: SDKMessage[]
/** Oldest event ID in this page → before_id cursor for next-older page. */
firstId: string | null
/** true = older events exist. */
hasMore: boolean
}

Expand All @@ -27,7 +48,6 @@ export type HistoryAuthCtx = {
headers: Record<string, string>
}

/** Prepare auth + headers + base URL once, reuse across pages. */
export async function createHistoryAuthCtx(
sessionId: string,
): Promise<HistoryAuthCtx> {
Expand Down Expand Up @@ -66,22 +86,99 @@ async function fetchPage(
}
}

/**
* Newest page: last `limit` events, chronological, via anchor_to_latest.
* has_more=true means older events exist.
*/
function extractSessionId(baseUrl: string): string {
const match = baseUrl.split('/v1/sessions/')[1]
return match ? match.split('/')[0] : 'default'
}

function serializeToCacheMessage(events: SDKMessage[]): CacheMessage[] {
return events.map((m): CacheMessage => ({
role: m.role,
content:
typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
tool_calls: m.tool_calls as CacheMessage['tool_calls'],
tool_use_id: m.tool_use_id,
}))
}

function deserializeFromCacheMessage(messages: CacheMessage[]): SDKMessage[] {
return messages.map((m): SDKMessage => ({
role: m.role,
content: m.content,
tool_calls: m.tool_calls as SDKMessage['tool_calls'],
tool_use_id: m.tool_use_id,
}))
}

export async function fetchLatestEvents(
ctx: HistoryAuthCtx,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')
const sessionId = extractSessionId(ctx.baseUrl)

// Check cache first before fetching from API
const cached = await loadCachedSession(sessionId)
if (cached) {
return {
events: cached,
firstId: null,
hasMore: true,
}
}

const page = await fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')

if (page && page.events.length > 0) {
// Cache and persist the fetched events
await cacheSession(sessionId, page.events)
}

return page
}

/** Older page: events immediately before `beforeId` cursor. */
export async function fetchOlderEvents(
ctx: HistoryAuthCtx,
beforeId: string,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
}

export async function cacheSession(
sessionId: string,
events: SDKMessage[],
): Promise<void> {
const cache = getHistoryCache()
const messages = serializeToCacheMessage(events)
cache.set(sessionId, messages)

const session = createSession(
messages as any,
{ model: process.env.OPENAI_MODEL },
)
session.id = sessionId
await saveSession(session)
}

export async function loadCachedSession(
sessionId: string,
): Promise<SDKMessage[] | null> {
const cache = getHistoryCache()
const cached = cache.get(sessionId)
if (cached) {
return deserializeFromCacheMessage(cached)
}

try {
const session = await loadSession(sessionId)
if (session) {
const events = session.messages as CacheMessage[]
cache.set(sessionId, events)
return deserializeFromCacheMessage(events)
}
} catch {
// Session not found or corrupt
}

return null
}
79 changes: 79 additions & 0 deletions src/utils/conversationCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it, beforeEach } from 'bun:test'
import { createConversationCache, type Message } from './conversationCache.js'

describe('conversationCache', () => {
let cache: ReturnType<typeof createConversationCache>

beforeEach(() => {
cache = createConversationCache({ maxSize: 3, ttlMs: 60000 })
})

describe('basic operations', () => {
it('stores and retrieves messages', () => {
const messages: Message[] = [
{ role: 'user', content: 'hello' },
]
cache.set('session1', messages)
expect(cache.get('session1')).toEqual(messages)
})

it('returns undefined for missing key', () => {
expect(cache.get('nonexistent')).toBeUndefined()
})

it('reports correct size', () => {
cache.set('a', [{ role: 'user', content: 'a' }])
cache.set('b', [{ role: 'user', content: 'b' }])
expect(cache.size).toBe(2)
})
})

describe('LRU eviction', () => {
it('evicts oldest when at capacity', () => {
cache.set('a', [{ role: 'user', content: 'a' }])
cache.set('b', [{ role: 'user', content: 'b' }])
cache.set('c', [{ role: 'user', content: 'c' }])
cache.set('d', [{ role: 'user', content: 'd' }]) // should evict 'a'

expect(cache.get('a')).toBeUndefined()
expect(cache.get('d')).toBeDefined()
})

it('updates access order on get', () => {
cache.set('a', [{ role: 'user', content: 'a' }])
cache.set('b', [{ role: 'user', content: 'b' }])
cache.get('a') // access 'a' to update order
cache.set('c', [{ role: 'user', content: 'c' }])
cache.set('d', [{ role: 'user', content: 'd' }]) // should evict 'b'

expect(cache.get('b')).toBeUndefined()
expect(cache.get('a')).toBeDefined()
})
})

describe('TTL expiration', () => {
it('evicts expired entries', async () => {
const shortCache = createConversationCache({ ttlMs: 1 })
shortCache.set('x', [{ role: 'user', content: 'x' }])

await new Promise(r => setTimeout(r, 10))

expect(shortCache.get('x')).toBeUndefined()
})
})

describe('delete and clear', () => {
it('deletes specific key', () => {
cache.set('a', [{ role: 'user', content: 'a' }])
expect(cache.delete('a')).toBe(true)
expect(cache.get('a')).toBeUndefined()
})

it('clears all entries', () => {
cache.set('a', [{ role: 'user', content: 'a' }])
cache.set('b', [{ role: 'user', content: 'b' }])
cache.clear()
expect(cache.size).toBe(0)
})
})
})
Loading
Loading