feat: add conversation storage (#140)

This commit is contained in:
2026-06-16 20:16:03 +01:00
committed by GitHub
parent 95f6c99f19
commit e11051b04b
23 changed files with 3457 additions and 396 deletions

View File

@@ -4,9 +4,12 @@ import { LocationSource } from "@freya/source-location"
import { WeatherSource } from "@freya/source-weatherkit"
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { ConversationStorageEntry } from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import type { Database } from "../db/index.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { CredentialEncryptor } from "../lib/crypto.ts"
import {
CredentialStorageUnavailableError,
@@ -21,6 +24,8 @@ import { UserSessionManager } from "./user-session-manager.ts"
* Key = userId (or "*" for a default), value = array of enabled sourceIds.
*/
const enabledByUser = new Map<string, string[]>()
const conversationEntriesByUser = new Map<string, ConversationStorageEntry[]>()
const mockConversationCalls: Array<{ type: "getOrCreate" | "listEntries"; userId: string }> = []
/** Set which sourceIds are enabled for all users. */
function setEnabledSources(sourceIds: string[]) {
@@ -37,6 +42,10 @@ function getEnabledSourceIds(userId: string): string[] {
return enabledByUser.get(userId) ?? enabledByUser.get("*") ?? []
}
function setConversationEntriesForUser(userId: string, entries: ConversationStorageEntry[]) {
conversationEntriesByUser.set(userId, entries)
}
/**
* Controls what `find()` returns in the mock. When `undefined` (the default),
* `find()` returns a standard enabled row. Set to a specific value (including
@@ -111,6 +120,35 @@ mock.module("../sources/user-sources.ts", () => ({
}),
}))
mock.module("../conversations/storage.ts", () => ({
conversations: (_db: Database, userId: string) => ({
async getOrCreateConversation(): Promise<{ id: string }> {
mockConversationCalls.push({ type: "getOrCreate", userId })
return { id: `conversation-${userId}` }
},
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
mockConversationCalls.push({ type: "listEntries", userId })
return conversationEntriesByUser.get(userId) ?? []
},
async appendEntry(
_conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
const entries = conversationEntriesByUser.get(userId) ?? []
const row: ConversationStorageEntry = {
id: `entry-${entries.length + 1}`,
sequence: entries.length + 1,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
conversationEntriesByUser.set(userId, [...entries, row])
return row
},
}),
}))
const fakeDb = {
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
@@ -160,6 +198,8 @@ const weatherProvider: FeedSourceProvider = {
beforeEach(() => {
enabledByUser.clear()
conversationEntriesByUser.clear()
mockConversationCalls.length = 0
mockFindResult = undefined
mockUpdateCredentialsCalls.length = 0
mockUpdateCredentialsError = null
@@ -176,6 +216,31 @@ describe("UserSessionManager", () => {
expect(session.engine).toBeDefined()
})
test("getOrCreate eagerly loads conversation entries for the user session", async () => {
setEnabledSources([])
setConversationEntriesForUser("user-1", [
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "stored hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
])
const manager = new UserSessionManager({ db: fakeDb, providers: [] })
await manager.getOrCreate("user-1")
expect(mockConversationCalls).toEqual([
{ type: "getOrCreate", userId: "user-1" },
{ type: "listEntries", userId: "user-1" },
])
})
test("getOrCreate returns same session for same user", async () => {
setEnabledSources(["freya.location"])
const manager = new UserSessionManager({ db: fakeDb, providers: [locationProvider] })

View File

@@ -8,6 +8,7 @@ import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { CredentialEncryptor } from "../lib/crypto.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { conversations } from "../conversations/storage.ts"
import {
CredentialStorageUnavailableError,
InvalidSourceConfigError,
@@ -362,6 +363,7 @@ export class UserSessionManager {
private async createSession(userId: string): Promise<UserSession> {
const enabledRows = await sources(this.db, userId).enabled()
const agentConfig = this.queryAgentConfigForUser(userId)
const promises: Promise<FeedSource>[] = []
for (const row of enabledRows) {
@@ -373,7 +375,7 @@ export class UserSessionManager {
}
if (promises.length === 0) {
return new UserSession(userId, [], this.feedEnhancer, this.queryAgentConfig)
return this.initializedSession(userId, [], agentConfig)
}
const results = await Promise.allSettled(promises)
@@ -397,7 +399,29 @@ export class UserSessionManager {
console.error("[UserSessionManager] Feed source provider failed:", error)
}
return new UserSession(userId, feedSources, this.feedEnhancer, this.queryAgentConfig)
return this.initializedSession(userId, feedSources, agentConfig)
}
private queryAgentConfigForUser(userId: string): UserSessionAgentConfig {
return {
...(this.queryAgentConfig ?? {}),
conversationStorage: conversations(this.db, userId),
}
}
private async initializedSession(
userId: string,
sources: FeedSource[],
agentConfig: UserSessionAgentConfig,
): Promise<UserSession> {
const session = new UserSession(userId, sources, this.feedEnhancer, agentConfig)
try {
await session.initialize()
return session
} catch (err) {
session.destroy()
throw err
}
}
/**

View File

@@ -3,6 +3,13 @@ import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@frey
import { LocationSource } from "@freya/source-location"
import { describe, expect, spyOn, test } from "bun:test"
import type {
ConversationStorage,
ConversationStorageEntry,
} from "../agent/conversation-recording-query-agent.ts"
import type { AppendConversationEntryInput } from "../conversations/storage.ts"
import { ConversationEntryKind } from "../conversations/types.ts"
import { UserSession } from "./user-session.ts"
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
@@ -23,6 +30,40 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
}
}
class FakeConversationStorage implements ConversationStorage {
readonly calls: string[] = []
private readonly entries: ConversationStorageEntry[]
constructor(entries: ConversationStorageEntry[] = []) {
this.entries = entries
}
async getOrCreateConversation(): Promise<{ id: string }> {
this.calls.push("getOrCreateConversation")
return { id: "conversation-1" }
}
async appendEntry(
_conversationId: string,
input: AppendConversationEntryInput,
): Promise<ConversationStorageEntry> {
this.calls.push("appendEntry")
return {
id: "entry-appended",
sequence: 1,
kind: input.kind,
payload: input.payload,
metadata: input.metadata ?? {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
}
}
async listEntries(_conversationId: string): Promise<ConversationStorageEntry[]> {
this.calls.push("listEntries")
return this.entries
}
}
describe("UserSession", () => {
test("registers sources and starts engine", async () => {
const session = new UserSession("test-user", [
@@ -67,6 +108,32 @@ describe("UserSession", () => {
expect(disposeSpy).toHaveBeenCalled()
})
test("initialize loads conversation entries before exposing stored agent", async () => {
const storage = new FakeConversationStorage([
{
id: "entry-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
payload: {
role: "user",
parts: [{ type: "text", text: "stored hello" }],
},
metadata: {},
createdAt: new Date("2026-06-15T00:00:00.000Z"),
},
])
const session = new UserSession("test-user", [createStubSource("test")], null, {
conversationStorage: storage,
})
expect(() => session.agent).toThrow("UserSession has not been initialized")
await session.initialize()
expect(storage.calls).toEqual(["getOrCreateConversation", "listEntries"])
expect(session.agent).toBeDefined()
})
test("engine.executeAction routes to correct source", async () => {
const location = new LocationSource()
const session = new UserSession("test-user", [location])

View File

@@ -10,22 +10,30 @@ import type { QueryAgentToolbox } from "../agent/query-agent-toolbox.ts"
import type { QueryAgent } from "../agent/query-agent.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import { PiQueryAgent } from "../agent/pi-query-agent.ts"
import {
ConversationRecordingQueryAgent,
type ConversationStorage,
} from "../agent/conversation-recording-query-agent.ts"
import { PiQueryAgent, PI_MODEL_ID, PI_MODEL_PROVIDER } from "../agent/pi-query-agent.ts"
import { UserSessionQueryAgentToolbox } from "../agent/user-session-query-agent-toolbox.ts"
export interface UserSessionAgentConfig {
apiKey?: string
cwd?: string
systemPrompt?: string
conversationStorage?: ConversationStorage
}
export class UserSession {
readonly userId: string
readonly engine: FeedEngine
readonly toolbox: QueryAgentToolbox
readonly agent: QueryAgent
private sources = new Map<string, FeedSource>()
private readonly enhancer: FeedEnhancer | null
private readonly agentConfig: UserSessionAgentConfig | undefined
private queryAgent: QueryAgent | null = null
private initializePromise: Promise<void> | null = null
private initialized = false
private enhancedItems: FeedItem[] | null = null
/** The FeedResult that enhancedItems was derived from. */
private enhancedSource: FeedResult | null = null
@@ -41,6 +49,7 @@ export class UserSession {
this.userId = userId
this.engine = new FeedEngine()
this.enhancer = enhancer ?? null
this.agentConfig = agentConfig
for (const source of sources) {
this.sources.set(source.id, source)
this.engine.register(source)
@@ -54,17 +63,43 @@ export class UserSession {
}
this.toolbox = new UserSessionQueryAgentToolbox(this)
this.agent = new PiQueryAgent({
userId: this.userId,
toolbox: this.toolbox,
apiKey: agentConfig?.apiKey,
cwd: agentConfig?.cwd,
systemPrompt: agentConfig?.systemPrompt,
})
if (!agentConfig?.conversationStorage) {
this.queryAgent = new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
})
this.initialized = true
}
this.engine.start()
}
get agent(): QueryAgent {
if (!this.queryAgent) {
throw new Error("UserSession has not been initialized")
}
return this.queryAgent
}
async initialize(): Promise<void> {
if (this.initialized) return
if (this.initializePromise) return this.initializePromise
const promise = this.initializeAgent()
this.initializePromise = promise
try {
await promise
this.initialized = true
} finally {
if (this.initializePromise === promise) {
this.initializePromise = null
}
}
}
/**
* Returns the current feed, refreshing if the engine cache expired.
* Enhancement runs eagerly on engine updates; this method awaits
@@ -201,7 +236,8 @@ export class UserSession {
}
destroy(): void {
this.agent.dispose()
this.queryAgent?.dispose()
this.queryAgent = null
this.unsubscribe?.()
this.unsubscribe = null
this.engine.stop()
@@ -210,6 +246,38 @@ export class UserSession {
this.enhancingPromise = null
}
private async initializeAgent(): Promise<void> {
if (this.queryAgent) return
const conversationStorage = this.agentConfig?.conversationStorage
if (!conversationStorage) {
this.queryAgent = new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
})
return
}
const conversation = await conversationStorage.getOrCreateConversation()
const entries = await conversationStorage.listEntries(conversation.id)
this.queryAgent = new ConversationRecordingQueryAgent({
agent: new PiQueryAgent({
toolbox: this.toolbox,
apiKey: this.agentConfig?.apiKey,
cwd: this.agentConfig?.cwd,
systemPrompt: this.agentConfig?.systemPrompt,
initialEntries: entries,
}),
storage: conversationStorage,
defaultConversationId: conversation.id,
modelProvider: PI_MODEL_PROVIDER,
modelId: PI_MODEL_ID,
})
}
private invalidateEnhancement(): void {
this.enhancedItems = null
this.enhancedSource = null