Compare commits

..

4 Commits

Author SHA1 Message Date
2e3ec94f96 feat: add tool payload schemas 2026-07-04 15:01:06 +01:00
350e1f5fcb feat: add agent protocol events 2026-07-04 15:01:06 +01:00
9aaefda216 feat: add conversation schemas (#155) 2026-07-02 23:54:08 +01:00
952f8e4fb0 dev: add nvim config (#153) 2026-06-29 15:58:15 +01:00
8 changed files with 412 additions and 39 deletions

View File

@@ -1,12 +1,22 @@
import type { Context, Hono } from "hono" import type { Context, Hono } from "hono"
import { ConversationEntryVisibility } from "@freya/core" import {
AssistantMessagePayload,
AttachmentPayload,
ConversationEntryKind,
ConversationEntryVisibility,
ContextSummaryPayload,
GenericObjectPayload,
UserMessagePayload,
type Conversation,
type ConversationEntry,
} from "@freya/core"
import { type } from "arktype" import { type } from "arktype"
import { createMiddleware } from "hono/factory" import { createMiddleware } from "hono/factory"
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { ConversationRow } from "./storage.ts" import type { ConversationEntryRow, ConversationRow } from "./storage.ts"
import { ConversationNotFoundError } from "./errors.ts" import { ConversationNotFoundError } from "./errors.ts"
import { conversations } from "./storage.ts" import { conversations } from "./storage.ts"
@@ -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. */ /** Dependencies required to register conversation HTTP handlers. */
interface ConversationsHttpHandlersDeps { interface ConversationsHttpHandlersDeps {
db: Database db: Database
authSessionMiddleware: AuthSessionMiddleware authSessionMiddleware: AuthSessionMiddleware
} }
interface ListConversationsResponse {
conversations: Conversation[]
}
interface ListConversationEntriesResponse {
entries: ConversationEntry[]
}
const ConversationIdParam = type("string.uuid") const ConversationIdParam = type("string.uuid")
export function registerConversationsHttpHandlers( export function registerConversationsHttpHandlers(
@@ -49,12 +60,13 @@ export function registerConversationsHttpHandlers(
async function handleListConversations(c: Context<Env>) { async function handleListConversations(c: Context<Env>) {
const user = c.get("user")! const user = c.get("user")!
const db = c.get("db") const db = c.get("db")
const response: ListConversationsResponse = {
return c.json({
conversations: (await conversations(db, user.id).listConversations()).map( conversations: (await conversations(db, user.id).listConversations()).map(
serializeConversation, serializeConversation,
), ),
}) }
return c.json(response)
} }
async function handleListEntries(c: Context<Env>) { async function handleListEntries(c: Context<Env>) {
@@ -73,20 +85,11 @@ async function handleListEntries(c: Context<Env>) {
const entries = await conversations(db, user.id).listEntries(parsedConversationId, { const entries = await conversations(db, user.id).listEntries(parsedConversationId, {
visibility: ConversationEntryVisibility.UserVisible, visibility: ConversationEntryVisibility.UserVisible,
}) })
const response: ListConversationEntriesResponse = {
entries: entries.map(serializeConversationEntry),
}
return c.json({ return c.json(response)
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(),
})),
})
} catch (err) { } catch (err) {
if (err instanceof ConversationNotFoundError) { if (err instanceof ConversationNotFoundError) {
return c.json({ error: "Conversation not found" }, 404) return c.json({ error: "Conversation not found" }, 404)
@@ -95,10 +98,89 @@ async function handleListEntries(c: Context<Env>) {
} }
} }
function serializeConversation(row: ConversationRow): ConversationSummaryResponse { function serializeConversation(row: ConversationRow): Conversation {
return { return {
id: row.id, id: row.id,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.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: GenericObjectPayload.assert(row.payload),
}
case ConversationEntryKind.ToolResult:
return {
...base,
kind: row.kind,
fileId: nullFileId(row),
payload: GenericObjectPayload.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
}

View File

@@ -158,6 +158,9 @@
"packages/freya-agent-protocol": { "packages/freya-agent-protocol": {
"name": "@freya/agent-protocol", "name": "@freya/agent-protocol",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"@freya/core": "workspace:*",
},
}, },
"packages/freya-components": { "packages/freya-components": {
"name": "@freya/components", "name": "@freya/components",

View File

@@ -6,5 +6,8 @@
"types": "src/index.ts", "types": "src/index.ts",
"scripts": { "scripts": {
"test": "bun test ./src" "test": "bun test ./src"
},
"dependencies": {
"@freya/core": "workspace:*"
} }
} }

View File

@@ -1,20 +1,40 @@
import { ConversationEntryKind, ConversationEntryVisibility } from "@freya/core"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { AgentEvent, AgentServerApi } from "./index" import { AgentEventKind, type AgentEvent, type AgentServerApi } from "./index"
describe("agent protocol", () => { describe("agent protocol", () => {
test("defines server methods and agent events", () => { test("defines server methods and agent events", () => {
const server: AgentServerApi = { const server: AgentServerApi = {
async sendMessage(message) { async sendMessage(message) {
return { message, conversationId: "conversation-1" } return {
id: "entry-1",
conversationId: "conversation-1",
sequence: 1,
kind: ConversationEntryKind.UserMessage,
visibility: ConversationEntryVisibility.UserVisible,
fileId: null,
payload: {
role: "user",
parts: [{ type: "text", text: message }],
},
metadata: {},
createdAt: "2026-07-03T00:00:00.000Z",
}
},
notify() {
// no-op for protocol shape test
}, },
ping() { ping() {
return "pong" return "pong"
}, },
} }
const event: AgentEvent = { type: "message_finished" } const event: AgentEvent = {
kind: AgentEventKind.ResponseFinished,
conversationId: "conversation-1",
}
expect(server.ping()).toBe("pong") expect(server.ping()).toBe("pong")
expect(event.type).toBe("message_finished") expect(event.kind).toBe(AgentEventKind.ResponseFinished)
}) })
}) })

View File

@@ -1,18 +1,46 @@
export interface SendMessageResult { import type { ConversationEntry } from "@freya/core"
message: string
export const AgentEventKind = {
ConversationStarted: "conversation_started",
ConversationEntryCreated: "conversation_entry_created",
ResponseFinished: "response_finished",
ResponseFailed: "response_failed",
} as const
export type AgentEventKind = (typeof AgentEventKind)[keyof typeof AgentEventKind]
export interface AgentConversationStartedEvent {
kind: typeof AgentEventKind.ConversationStarted
conversationId: string conversationId: string
} }
export interface AgentConversationEntryCreatedEvent {
kind: typeof AgentEventKind.ConversationEntryCreated
entry: ConversationEntry
}
export interface AgentResponseFinishedEvent {
kind: typeof AgentEventKind.ResponseFinished
conversationId: string
}
export interface AgentResponseFailedEvent {
kind: typeof AgentEventKind.ResponseFailed
conversationId: string
error: string
}
export type AgentEvent = export type AgentEvent =
| { type: "conversation_started"; conversationId: string } | AgentConversationStartedEvent
| { type: "message_created"; text: string } | AgentConversationEntryCreatedEvent
| { type: "tool_started"; toolName: string } | AgentResponseFinishedEvent
| { type: "tool_finished"; toolName: string; ok: boolean } | AgentResponseFailedEvent
| { type: "message_finished" }
| { type: "message_failed"; error: string } export type UserEvent = { type: "typing" }
export interface AgentServerApi { export interface AgentServerApi {
sendMessage(message: string): Promise<SendMessageResult> sendMessage(message: string): Promise<ConversationEntry>
notify(event: UserEvent): void
ping(): "pong" ping(): "pong"
} }

View File

@@ -4,7 +4,11 @@ import {
AttachmentType, AttachmentType,
AttachmentPayload, AttachmentPayload,
ContextSummaryPayload, ContextSummaryPayload,
Conversation,
ConversationEntry,
ConversationEntryKind,
ConversationEntryMetadata, ConversationEntryMetadata,
ConversationEntryVisibility,
GenericObjectPayload, GenericObjectPayload,
UserMessagePayload, UserMessagePayload,
} from "./conversation" } from "./conversation"
@@ -143,4 +147,99 @@ describe("conversation entry schemas", () => {
}), }),
).toThrow() ).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()
})
}) })

View File

@@ -146,6 +146,19 @@ export const ConversationEntryMetadata = type({
/** Metadata bag attached to a conversation entry. */ /** Metadata bag attached to a conversation entry. */
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer 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. */ /** Generic object payload used by operational entries. */
export const GenericObjectPayload = type("Record<string, unknown>") export const GenericObjectPayload = type("Record<string, unknown>")
@@ -158,4 +171,118 @@ export type ConversationEntryPayload =
| AssistantMessagePayload | AssistantMessagePayload
| AttachmentPayload | AttachmentPayload
| ContextSummaryPayload | ContextSummaryPayload
| ToolCallPayload
| ToolResultPayload
| GenericObjectPayload | 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: GenericObjectPayload,
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: GenericObjectPayload,
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

View File

@@ -10,10 +10,15 @@ export { UnknownActionError } from "./action"
export type { ConversationEntryPayload } from "./conversation" export type { ConversationEntryPayload } from "./conversation"
export { export {
AssistantMessagePayload, AssistantMessagePayload,
AssistantMessageConversationEntry,
AttachmentPayload, AttachmentPayload,
AttachmentConversationEntry,
AttachmentType, AttachmentType,
ContextSummary, ContextSummary,
ContextSummaryConversationEntry,
ContextSummaryPayload, ContextSummaryPayload,
Conversation,
ConversationEntry,
ConversationEntryKind, ConversationEntryKind,
ConversationEntryMetadata, ConversationEntryMetadata,
ConversationEntryVisibility, ConversationEntryVisibility,
@@ -21,8 +26,14 @@ export {
JsonMessagePart, JsonMessagePart,
MessagePart, MessagePart,
ModelRunMetadata, ModelRunMetadata,
SystemNoteConversationEntry,
TextMessagePart, TextMessagePart,
ToolCallConversationEntry,
ToolCallPayload,
ToolResultConversationEntry,
ToolResultPayload,
UserMessagePayload, UserMessagePayload,
UserMessageConversationEntry,
} from "./conversation" } from "./conversation"
// Feed // Feed