From 05abfe0c980335987b50f9aff69f6c0e9edab7a4 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 2 Jul 2026 21:31:03 +0100 Subject: [PATCH] feat: add conversation schemas --- apps/freya-backend/src/conversations/http.ts | 140 ++++++++++++++---- .../src/conversations/conversation-list.tsx | 3 +- .../src/conversations/conversations.ts | 21 --- .../freya-client/src/conversations/queries.ts | 5 +- packages/freya-core/src/conversation.test.ts | 99 +++++++++++++ packages/freya-core/src/conversation.ts | 129 ++++++++++++++++ packages/freya-core/src/index.ts | 11 ++ 7 files changed, 354 insertions(+), 54 deletions(-) delete mode 100644 apps/freya-client/src/conversations/conversations.ts diff --git a/apps/freya-backend/src/conversations/http.ts b/apps/freya-backend/src/conversations/http.ts index 5cc8d86..34b7e32 100644 --- a/apps/freya-backend/src/conversations/http.ts +++ b/apps/freya-backend/src/conversations/http.ts @@ -1,14 +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 type { Context, Hono } from "hono" 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 { ConversationNotFoundError } from "./errors.ts" +import type { ConversationEntryRow, ConversationRow } from "./storage.ts" import { conversations } from "./storage.ts" /** Hono environment populated by the conversations route middleware. */ @@ -18,19 +28,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 +60,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 +85,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 +98,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/apps/freya-client/src/conversations/conversation-list.tsx b/apps/freya-client/src/conversations/conversation-list.tsx index 1efa463..5614a6d 100644 --- a/apps/freya-client/src/conversations/conversation-list.tsx +++ b/apps/freya-client/src/conversations/conversation-list.tsx @@ -1,4 +1,4 @@ -import { AssistantMessagePayload, UserMessagePayload } from "@freya/core" +import { AssistantMessagePayload, ConversationEntry, UserMessagePayload } from "@freya/core" import { FlashList } from "@shopify/flash-list" import { useQuery } from "@tanstack/react-query" import { View, ViewStyle } from "react-native" @@ -6,7 +6,6 @@ import tw from "twrnc" import { SansSerifText } from "@/components/ui/sans-serif-text" -import { ConversationEntry } from "./conversations" import { useListConversationEntriesQuery, useDefaultConversationQuery } from "./queries" type ConversationListProps = Omit< diff --git a/apps/freya-client/src/conversations/conversations.ts b/apps/freya-client/src/conversations/conversations.ts deleted file mode 100644 index 2c17f2a..0000000 --- a/apps/freya-client/src/conversations/conversations.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - ConversationEntryKind, - ConversationEntryPayload, - ConversationEntryVisibility, -} from "@freya/core" -import { type } from "arktype" - -export const Conversation = type({ - id: "string.uuid", - createdAt: "string.date.iso", - updatedAt: "string.date.iso", -}) - -export const ConversationEntry = type({ - id: "string.uuid", - sequence: "number", - kind: type.enumerated(...Object.values(ConversationEntryKind)), - visibility: type.enumerated(...Object.values(ConversationEntryVisibility)), - fileId: "string | null", - payload: ConversationEntryPayload, -}) diff --git a/apps/freya-client/src/conversations/queries.ts b/apps/freya-client/src/conversations/queries.ts index e140a1e..654bff4 100644 --- a/apps/freya-client/src/conversations/queries.ts +++ b/apps/freya-client/src/conversations/queries.ts @@ -1,15 +1,16 @@ +import { Conversation, ConversationEntry } from "@freya/core" import { queryOptions, skipToken } from "@tanstack/react-query" import { type } from "arktype" import { useApiClient } from "@/api/client" -import { Conversation, ConversationEntry } from "./conversations" - const ListConversationsResponse = type({ + "+": "reject", conversations: Conversation.array(), }) const ConversationEntriesResponse = type({ + "+": "reject", entries: ConversationEntry.array(), }) 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 ed6fdeb..db9574d 100644 --- a/packages/freya-core/src/conversation.ts +++ b/packages/freya-core/src/conversation.ts @@ -146,6 +146,19 @@ export const ConversationEntryMetadata = type({ /** Metadata bag attached to a conversation entry. */ export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer +export const ToolCallPayload = type({ + toolName: "string", +}) + +export type ToolCallPayload = typeof ToolCallPayload.infer + +export const ToolResultPayload = type({ + toolName: "string", + ok: "boolean", +}) + +export type ToolResultPayload = typeof ToolResultPayload.infer + /** Generic object payload used by operational entries. */ export const GenericObjectPayload = type("Record") @@ -157,6 +170,8 @@ export const ConversationEntryPayload = type.or( AssistantMessagePayload, AttachmentPayload, ContextSummaryPayload, + ToolCallPayload, + ToolResultPayload, GenericObjectPayload, ) @@ -166,4 +181,118 @@ export type ConversationEntryPayload = | AssistantMessagePayload | AttachmentPayload | ContextSummaryPayload + | 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 66cf5a2..59e8a78 100644 --- a/packages/freya-core/src/index.ts +++ b/packages/freya-core/src/index.ts @@ -9,10 +9,15 @@ export { UnknownActionError } from "./action"; // Conversation export { AssistantMessagePayload, + AssistantMessageConversationEntry, AttachmentPayload, + AttachmentConversationEntry, AttachmentType, + ContextSummaryConversationEntry, ContextSummary, ContextSummaryPayload, + Conversation, + ConversationEntry, ConversationEntryKind, ConversationEntryMetadata, ConversationEntryVisibility, @@ -20,9 +25,15 @@ export { JsonMessagePart, MessagePart, ModelRunMetadata, + SystemNoteConversationEntry, TextMessagePart, + ToolCallConversationEntry, + ToolCallPayload, + ToolResultConversationEntry, + ToolResultPayload, UserMessagePayload, ConversationEntryPayload, + UserMessageConversationEntry, } from "./conversation"; // Feed