diff --git a/apps/freya-backend/src/conversations/http.ts b/apps/freya-backend/src/conversations/http.ts index 2f7d86b..8ba9727 100644 --- a/apps/freya-backend/src/conversations/http.ts +++ b/apps/freya-backend/src/conversations/http.ts @@ -1,12 +1,24 @@ import type { Context, Hono } from "hono" -import { ConversationEntryVisibility } from "@freya/core" +import { + AssistantMessagePayload, + AttachmentPayload, + ConversationEntryKind, + ConversationEntryVisibility, + ContextSummaryPayload, + GenericObjectPayload, + ToolCallPayload, + ToolResultPayload, + UserMessagePayload, + type Conversation, + type ConversationEntry, +} from "@freya/core" import { type } from "arktype" import { createMiddleware } from "hono/factory" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { Database } from "../db/index.ts" -import type { ConversationRow } from "./storage.ts" +import type { ConversationEntryRow, ConversationRow } from "./storage.ts" import { conversations } from "./db-storage.ts" import { ConversationNotFoundError } from "./errors.ts" @@ -18,19 +30,20 @@ type Env = { } } -/** Serialized conversation summary returned by the list endpoint. */ -interface ConversationSummaryResponse { - id: string - createdAt: string - updatedAt: string -} - /** Dependencies required to register conversation HTTP handlers. */ interface ConversationsHttpHandlersDeps { db: Database authSessionMiddleware: AuthSessionMiddleware } +interface ListConversationsResponse { + conversations: Conversation[] +} + +interface ListConversationEntriesResponse { + entries: ConversationEntry[] +} + const ConversationIdParam = type("string.uuid") export function registerConversationsHttpHandlers( @@ -49,12 +62,13 @@ export function registerConversationsHttpHandlers( async function handleListConversations(c: Context) { const user = c.get("user")! const db = c.get("db") - - return c.json({ + const response: ListConversationsResponse = { conversations: (await conversations(db, user.id).listConversations()).map( serializeConversation, ), - }) + } + + return c.json(response) } async function handleListEntries(c: Context) { @@ -73,20 +87,11 @@ async function handleListEntries(c: Context) { const entries = await conversations(db, user.id).listEntries(parsedConversationId, { visibility: ConversationEntryVisibility.UserVisible, }) + const response: ListConversationEntriesResponse = { + entries: entries.map(serializeConversationEntry), + } - return c.json({ - entries: entries.map((row) => ({ - id: row.id, - conversationId: row.conversationId, - sequence: row.sequence, - kind: row.kind, - visibility: row.visibility, - fileId: row.fileId, - payload: row.payload, - metadata: row.metadata, - createdAt: row.createdAt.toISOString(), - })), - }) + return c.json(response) } catch (err) { if (err instanceof ConversationNotFoundError) { return c.json({ error: "Conversation not found" }, 404) @@ -95,10 +100,89 @@ async function handleListEntries(c: Context) { } } -function serializeConversation(row: ConversationRow): ConversationSummaryResponse { +function serializeConversation(row: ConversationRow): Conversation { return { id: row.id, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), } } + +function serializeConversationEntry(row: ConversationEntryRow): ConversationEntry { + const base = { + id: row.id, + conversationId: row.conversationId, + sequence: row.sequence, + visibility: row.visibility, + metadata: row.metadata, + createdAt: row.createdAt.toISOString(), + } + + switch (row.kind) { + case ConversationEntryKind.UserMessage: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: UserMessagePayload.assert(row.payload), + } + case ConversationEntryKind.AssistantMessage: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: AssistantMessagePayload.assert(row.payload), + } + case ConversationEntryKind.Attachment: + return { + ...base, + kind: row.kind, + fileId: requireFileId(row), + payload: AttachmentPayload.assert(row.payload), + } + case ConversationEntryKind.ToolCall: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: ToolCallPayload.assert(row.payload), + } + case ConversationEntryKind.ToolResult: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: ToolResultPayload.assert(row.payload), + } + case ConversationEntryKind.ContextSummary: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: ContextSummaryPayload.assert(row.payload), + } + case ConversationEntryKind.SystemNote: + return { + ...base, + kind: row.kind, + fileId: nullFileId(row), + payload: GenericObjectPayload.assert(row.payload), + } + } +} + +function requireFileId(row: ConversationEntryRow): string { + if (!row.fileId) { + throw new Error(`Conversation attachment entry "${row.id}" is missing a file id`) + } + + return row.fileId +} + +function nullFileId(row: ConversationEntryRow): null { + if (row.fileId !== null) { + throw new Error(`Conversation entry "${row.id}" unexpectedly references a file`) + } + + return null +} diff --git a/packages/freya-core/src/conversation.test.ts b/packages/freya-core/src/conversation.test.ts index 1297e05..96b69fc 100644 --- a/packages/freya-core/src/conversation.test.ts +++ b/packages/freya-core/src/conversation.test.ts @@ -4,7 +4,11 @@ import { AttachmentType, AttachmentPayload, ContextSummaryPayload, + Conversation, + ConversationEntry, + ConversationEntryKind, ConversationEntryMetadata, + ConversationEntryVisibility, GenericObjectPayload, UserMessagePayload, } from "./conversation" @@ -143,4 +147,99 @@ describe("conversation entry schemas", () => { }), ).toThrow() }) + + test("parses conversation summaries", () => { + const conversation = Conversation.assert({ + id: "11111111-1111-4111-8111-111111111111", + createdAt: "2026-06-17T09:30:00.000Z", + updatedAt: "2026-06-17T09:35:00.000Z", + }) + + expect(conversation.id).toBe("11111111-1111-4111-8111-111111111111") + }) + + test("parses kind-specific conversation entries", () => { + const userMessageEntry = ConversationEntry.assert({ + id: "22222222-2222-4222-8222-222222222222", + conversationId: "11111111-1111-4111-8111-111111111111", + sequence: 1, + kind: ConversationEntryKind.UserMessage, + visibility: ConversationEntryVisibility.UserVisible, + fileId: null, + payload: { + role: "user", + parts: [{ type: "text", text: "hello" }], + }, + metadata: {}, + createdAt: "2026-06-17T09:30:00.000Z", + }) + const attachmentEntry = ConversationEntry.assert({ + id: "33333333-3333-4333-8333-333333333333", + conversationId: "11111111-1111-4111-8111-111111111111", + sequence: 2, + kind: ConversationEntryKind.Attachment, + visibility: ConversationEntryVisibility.UserVisible, + fileId: "44444444-4444-4444-8444-444444444444", + payload: { + role: "user", + name: "photo.png", + mimeType: "image/png", + attachmentType: AttachmentType.Image, + }, + metadata: {}, + createdAt: "2026-06-17T09:31:00.000Z", + }) + + expect(userMessageEntry.kind).toBe(ConversationEntryKind.UserMessage) + expect(attachmentEntry.kind).toBe(ConversationEntryKind.Attachment) + }) + + test("rejects conversation entries whose payload does not match the kind", () => { + expect(() => + ConversationEntry.assert({ + id: "22222222-2222-4222-8222-222222222222", + conversationId: "11111111-1111-4111-8111-111111111111", + sequence: 1, + kind: ConversationEntryKind.UserMessage, + visibility: ConversationEntryVisibility.UserVisible, + fileId: null, + payload: { + role: "assistant", + parts: [{ type: "text", text: "hello" }], + }, + metadata: {}, + createdAt: "2026-06-17T09:30:00.000Z", + }), + ).toThrow() + }) + + test("rejects serialized conversations with extra fields", () => { + expect(() => + Conversation.assert({ + id: "11111111-1111-4111-8111-111111111111", + createdAt: "2026-06-17T09:30:00.000Z", + updatedAt: "2026-06-17T09:35:00.000Z", + title: "not yet part of the schema", + }), + ).toThrow() + }) + + test("rejects file ids on non-attachment entries", () => { + expect(() => + ConversationEntry.assert({ + id: "22222222-2222-4222-8222-222222222222", + conversationId: "11111111-1111-4111-8111-111111111111", + sequence: 1, + kind: ConversationEntryKind.UserMessage, + visibility: ConversationEntryVisibility.UserVisible, + fileId: "44444444-4444-4444-8444-444444444444", + payload: { + role: "user", + parts: [{ type: "text", text: "hello" }], + }, + metadata: {}, + createdAt: "2026-06-17T09:30:00.000Z", + }), + ).toThrow() + }) }) diff --git a/packages/freya-core/src/conversation.ts b/packages/freya-core/src/conversation.ts index d107f6f..db85067 100644 --- a/packages/freya-core/src/conversation.ts +++ b/packages/freya-core/src/conversation.ts @@ -174,3 +174,115 @@ export type ConversationEntryPayload = | ToolCallPayload | ToolResultPayload | GenericObjectPayload + +export const Conversation = type({ + "+": "reject", + id: "string.uuid", + createdAt: "string.date.iso", + updatedAt: "string.date.iso", +}) + +export type Conversation = typeof Conversation.infer + +export const UserMessageConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'user_message'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: UserMessagePayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const AssistantMessageConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'assistant_message'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: AssistantMessagePayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const AttachmentConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'attachment'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "string.uuid", + payload: AttachmentPayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const ToolCallConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'tool_call'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: ToolCallPayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const ToolResultConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'tool_result'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: ToolResultPayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const ContextSummaryConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'context_summary'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: ContextSummaryPayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const SystemNoteConversationEntry = type({ + "+": "reject", + id: "string.uuid", + conversationId: "string.uuid", + sequence: "number.integer >= 1", + kind: "'system_note'", + visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), + fileId: "null", + payload: GenericObjectPayload, + metadata: ConversationEntryMetadata, + createdAt: "string.date.iso", +}) + +export const ConversationEntry = type.or( + UserMessageConversationEntry, + AssistantMessageConversationEntry, + AttachmentConversationEntry, + ToolCallConversationEntry, + ToolResultConversationEntry, + ContextSummaryConversationEntry, + SystemNoteConversationEntry, +) + +export type ConversationEntry = typeof ConversationEntry.infer diff --git a/packages/freya-core/src/index.ts b/packages/freya-core/src/index.ts index 4ae7629..a4180a4 100644 --- a/packages/freya-core/src/index.ts +++ b/packages/freya-core/src/index.ts @@ -10,10 +10,15 @@ export { UnknownActionError } from "./action" export type { ConversationEntryPayload } from "./conversation" export { AssistantMessagePayload, + AssistantMessageConversationEntry, AttachmentPayload, + AttachmentConversationEntry, AttachmentType, ContextSummary, + ContextSummaryConversationEntry, ContextSummaryPayload, + Conversation, + ConversationEntry, ConversationEntryKind, ConversationEntryMetadata, ConversationEntryVisibility, @@ -21,10 +26,14 @@ export { JsonMessagePart, MessagePart, ModelRunMetadata, + SystemNoteConversationEntry, TextMessagePart, - UserMessagePayload, + ToolCallConversationEntry, ToolCallPayload, + ToolResultConversationEntry, ToolResultPayload, + UserMessagePayload, + UserMessageConversationEntry, } from "./conversation" // Feed