mirror of
https://github.com/kennethnym/freya
synced 2026-07-03 06:41:15 +01:00
feat: add conversation storage (#140)
This commit is contained in:
@@ -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] })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user