import { ConversationEntryKind } from "@freya/core" import { describe, expect, test } from "bun:test" import type { AppendConversationEntryInput } from "../conversations/storage.ts" import type { ConversationStorage, ConversationStorageEntry, } from "./conversation-recording-query-agent.ts" import { ConversationRecordingQueryAgent } from "./conversation-recording-query-agent.ts" import { createQueryAgentEventListeners, QueryAgentEvent, type QueryAgent, type QueryAgentAsk, type QueryAgentCompactionEvent, type QueryAgentEventListeners, type QueryAgentEventListener, type QueryAgentEventMap, type QueryAgentStreamEvent, } from "./query-agent.ts" interface RecordedEntry { conversationId: string input: AppendConversationEntryInput } class FakeQueryAgent implements QueryAgent { readonly inputs: QueryAgentAsk[] = [] private readonly events: QueryAgentStreamEvent[] private readonly eventListeners = createQueryAgentEventListeners() constructor(events: QueryAgentStreamEvent[]) { this.events = events } async *ask(input: QueryAgentAsk): AsyncIterable { this.inputs.push(input) for (const event of this.events) { yield event } } addEventListener( type: T, listener: QueryAgentEventListener, ): () => void { const listeners = this.listenersFor(type) listeners.add(listener) return () => { listeners.delete(listener) } } async emitCompaction(event: QueryAgentCompactionEvent): Promise { await this.emitEvent(event) } private async emitEvent(event: QueryAgentEventMap[T]): Promise { const listeners = this.listenersFor(event.type) for (const listener of listeners) { await listener(event) } } private listenersFor(type: T): QueryAgentEventListeners[T] { return this.eventListeners[type] } dispose(): void {} } class FakeConversationStorage implements ConversationStorage { getOrCreateCount = 0 readonly entries: RecordedEntry[] = [] conversationId = "conversation-1" async getOrCreateConversation(): Promise<{ id: string }> { this.getOrCreateCount += 1 return { id: this.conversationId } } async appendEntry( conversationId: string, input: AppendConversationEntryInput, ): Promise { this.entries.push({ conversationId, input }) return { id: `entry-${this.entries.length}`, sequence: this.entries.length, kind: input.kind, payload: input.payload, metadata: input.metadata ?? {}, createdAt: new Date("2026-06-15T00:00:00.000Z"), } } async listEntries(_conversationId: string): Promise { return this.entries.map((entry, index) => ({ id: `entry-${index + 1}`, sequence: index + 1, kind: entry.input.kind, payload: entry.input.payload, metadata: entry.input.metadata ?? {}, createdAt: new Date("2026-06-15T00:00:00.000Z"), })) } } describe("ConversationRecordingQueryAgent", () => { test("records user and assistant messages in the conversation timeline", async () => { const queryAgent = new FakeQueryAgent([ { type: "text_delta", text: "Hello " }, { type: "text_delta", text: "there." }, { type: "done" }, ]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, modelProvider: "openrouter", modelId: "test-model", }) const events = await collectEvents( agent.ask({ message: "hi", }), ) expect(events[0]).toEqual({ type: "conversation", conversationId: "conversation-1" }) expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-1") expect(storage.getOrCreateCount).toBe(1) expect(storage.entries).toHaveLength(2) const userEntry = storage.entries[0]!.input if (userEntry.kind !== ConversationEntryKind.UserMessage) { throw new Error("Expected user message entry") } expect(userEntry.payload.parts).toEqual([{ type: "text", text: "hi" }]) const assistantEntry = storage.entries[1]!.input if (assistantEntry.kind !== ConversationEntryKind.AssistantMessage) { throw new Error("Expected assistant message entry") } expect(assistantEntry.payload.parts).toEqual([{ type: "text", text: "Hello there." }]) expect(assistantEntry.metadata?.modelRun?.provider).toBe("openrouter") expect(assistantEntry.metadata?.modelRun?.model).toBe("test-model") }) test("uses a provided conversation id without creating a default conversation", async () => { const queryAgent = new FakeQueryAgent([{ type: "done" }]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, modelProvider: "openrouter", modelId: "test-model", }) const events = await collectEvents( agent.ask({ conversationId: "conversation-existing", message: "continue", }), ) expect(events[0]).toEqual({ type: "conversation", conversationId: "conversation-existing", }) expect(storage.getOrCreateCount).toBe(0) expect(storage.entries[0]?.conversationId).toBe("conversation-existing") expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-existing") }) test("uses the eager default conversation id without reading storage on ask", async () => { const queryAgent = new FakeQueryAgent([{ type: "done" }]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, defaultConversationId: "conversation-eager", modelProvider: "openrouter", modelId: "test-model", }) const events = await collectEvents( agent.ask({ message: "continue", }), ) expect(events[0]).toEqual({ type: "conversation", conversationId: "conversation-eager", }) expect(storage.getOrCreateCount).toBe(0) expect(storage.entries[0]?.conversationId).toBe("conversation-eager") expect(queryAgent.inputs[0]?.conversationId).toBe("conversation-eager") }) test("rejects switching away from the eager default conversation", async () => { const queryAgent = new FakeQueryAgent([{ type: "done" }]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, defaultConversationId: "conversation-eager", modelProvider: "openrouter", modelId: "test-model", }) const events = await collectEvents( agent.ask({ conversationId: "conversation-other", message: "continue", }), ) expect(events).toEqual([ { type: "error", message: "Conversation switching is not supported for this session", }, ]) expect(storage.entries).toHaveLength(0) expect(queryAgent.inputs).toHaveLength(0) }) test("records tool activity and agent errors as internal entries", async () => { const queryAgent = new FakeQueryAgent([ { type: "tool_start", toolName: "freya_get_feed" }, { type: "tool_end", toolName: "freya_get_feed", ok: true }, { type: "error", message: "model unavailable" }, ]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, modelProvider: "openrouter", modelId: "test-model", }) await collectEvents( agent.ask({ message: "what now?", }), ) expect(storage.entries.map((entry) => entry.input.kind)).toEqual([ ConversationEntryKind.UserMessage, ConversationEntryKind.ToolCall, ConversationEntryKind.ToolResult, ConversationEntryKind.SystemNote, ]) const toolCall = storage.entries[1]!.input if (toolCall.kind !== ConversationEntryKind.ToolCall) { throw new Error("Expected tool call entry") } expect(toolCall.payload.toolName).toBe("freya_get_feed") const toolResult = storage.entries[2]!.input if (toolResult.kind !== ConversationEntryKind.ToolResult) { throw new Error("Expected tool result entry") } expect(toolResult.payload.ok).toBe(true) const systemNote = storage.entries[3]!.input if (systemNote.kind !== ConversationEntryKind.SystemNote) { throw new Error("Expected system note entry") } expect(systemNote.payload).toMatchObject({ type: "agent_error", message: "model unavailable", }) }) test("records compaction events as context summaries", async () => { const queryAgent = new FakeQueryAgent([ { type: "text_delta", text: "Kept answer." }, { type: "done" }, ]) const storage = new FakeConversationStorage() const agent = new ConversationRecordingQueryAgent({ agent: queryAgent, storage, defaultConversationId: "conversation-1", modelProvider: "openrouter", modelId: "test-model", }) const forwardedCompactions: QueryAgentCompactionEvent[] = [] agent.addEventListener(QueryAgentEvent.Compaction, (event) => { forwardedCompactions.push(event) }) await collectEvents( agent.ask({ message: "remember this", }), ) await queryAgent.emitCompaction({ type: QueryAgentEvent.Compaction, conversationId: "conversation-1", summary: "The user prefers compact summaries.", firstKeptEntryId: "pi-entry-7", compactedEntryRange: { startSequence: 1, endSequence: 1, }, tokensBefore: 1234, details: { reason: "threshold" }, fromExtension: false, }) const summaryEntry = storage.entries.at(-1)?.input if (summaryEntry?.kind !== ConversationEntryKind.ContextSummary) { throw new Error("Expected context summary entry") } expect(summaryEntry.payload.covers).toEqual({ startSequence: 1, endSequence: 1, }) expect(summaryEntry.payload.summary.importantDetails).toEqual([ "The user prefers compact summaries.", ]) expect(summaryEntry.metadata?.piCompaction).toMatchObject({ firstKeptEntryId: "pi-entry-7", tokensBefore: 1234, fromExtension: false, details: { reason: "threshold" }, }) expect(forwardedCompactions).toHaveLength(1) }) }) async function collectEvents( events: AsyncIterable, ): Promise { const result: QueryAgentStreamEvent[] = [] for await (const event of events) { result.push(event) } return result }