feat: AG-UI as in-process KMP API with Ktor client/server transports

Model the AG-UI protocol as an in-process API of Java-style interfaces and
classes, mirroring the official AG-UI Java SDK, and build it as a Kotlin
Multiplatform library with two Ktor transport modules.

Modules:
- api: BaseEvent sealed hierarchy (all AG-UI event types), messages, state,
  tool, context, RunAgentInput/Parameters, Agent/AgentSubscriber/
  EventDispatcher, AguiJson (kotlinx.serialization, "type" discriminator),
  plus a SerializersModule registering every event subtype polymorphically.
- client: HttpAgent over Ktor SSE (POST RunAgentInput -> stream BaseEvent),
  implementing the Agent interface.
- server: Application.agui()/Route.aguiAgent() exposing an Agent behind a
  POST + SSE endpoint via SSEServerContent, plus a runnable sample.

Tests: api serialization round-trips, client SSE decode against a real
test-host server, server endpoint integration.
This commit is contained in:
2026-09-09 22:11:08 +03:00
parent a5d6c38573
commit fed7f7ab71
30 changed files with 1619 additions and 0 deletions
@@ -0,0 +1,51 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.message.Message
import com.agui.api.run.RunAgentInput
import com.agui.api.run.RunAgentParameters
import com.agui.api.run.randomRunId
import com.agui.api.run.toParameters
import com.agui.api.run.toRunAgentInput
import com.agui.api.state.State
import com.agui.api.state.mutableState
import kotlinx.coroutines.flow.Flow
/**
* Convenience base class for building a stateful, local [Agent].
*
* It owns the agent's [agentId], [threadId], a mutable [state] and its own [messages],
* and adapts the wire-level [run] to the higher-level [runAgent] taking only
* [RunAgentParameters].
*
* Subclasses override [runAgent]; typically they call [toRunAgentInput] to obtain the full
* [RunAgentInput] for the run and return a flow of events.
*/
public abstract class AbstractAgent(
public val agentId: String,
public val threadId: String,
) : Agent {
/** The agent's mutable state, exposed as an immutable [State] view. */
public val state: State = mutableState()
/** The agent's own running message list, merged into the input if none is supplied. */
public val messages: MutableList<Message> = mutableListOf()
/** Subclasses produce the event stream for a single run. */
public abstract fun runAgent(parameters: RunAgentParameters): Flow<BaseEvent>
/** Adapts [input] to [runAgent], binding the run to *this* agent's identity and state. */
final override fun run(input: RunAgentInput): Flow<BaseEvent> =
runAgent(input.toParameters())
/** Builds a [RunAgentInput] from [parameters], this agent's [threadId] and [state]. */
public fun toRunAgentInput(
parameters: RunAgentParameters,
state: State = this.state,
): RunAgentInput = parameters.toRunAgentInput(
threadId = threadId,
runId = parameters.runId ?: randomRunId(),
state = state,
)
}
@@ -0,0 +1,21 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.run.RunAgentInput
import kotlinx.coroutines.flow.Flow
/**
* The core AG-UI abstraction: an entity that, given a complete [RunAgentInput], produces the
* agent's stream of [BaseEvent]s.
*
* This is the network-agnostic heart of the protocol. A concrete [Agent] can be:
*
* - remote — [com.agui.client.HttpAgent] streams events over SSE;
* - local — a real agent implementation;
* - composed/decorated — logging, auth, retry, observability wrappers.
*/
public fun interface Agent {
/** Runs the agent for [input] and returns the resulting event stream. */
public fun run(input: RunAgentInput): Flow<BaseEvent>
}
@@ -0,0 +1,85 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
/**
* Observer for a stream of [BaseEvent]s.
*
* Every method is a no-op by default, so implementations only override the events
* they care about. Dispatch a [BaseEvent] to this subscriber with
* [EventDispatcher.dispatch], or collect a whole flow with `dispatchTo`.
*/
public interface AgentSubscriber {
/** Called for every event, before the type-specific callback. */
public fun onEvent(event: BaseEvent) {
}
public fun onRunStarted(event: RunStartedEvent) {
}
public fun onRunFinished(event: RunFinishedEvent) {
}
public fun onRunError(event: RunErrorEvent) {
}
public fun onStepStarted(event: StepStartedEvent) {
}
public fun onStepFinished(event: StepFinishedEvent) {
}
public fun onTextMessageStart(event: TextMessageStartEvent) {
}
public fun onTextMessageContent(event: TextMessageContentEvent) {
}
public fun onTextMessageEnd(event: TextMessageEndEvent) {
}
public fun onToolCallStart(event: ToolCallStartEvent) {
}
public fun onToolCallArgs(event: ToolCallArgsEvent) {
}
public fun onToolCallEnd(event: ToolCallEndEvent) {
}
public fun onToolCallResult(event: ToolCallResultEvent) {
}
public fun onStateSnapshot(event: StateSnapshotEvent) {
}
public fun onStateDelta(event: StateDeltaEvent) {
}
public fun onMessagesSnapshot(event: MessagesSnapshotEvent) {
}
public fun onRaw(event: RawEvent) {
}
public fun onCustom(event: CustomEvent) {
}
}
@@ -0,0 +1,56 @@
package com.agui.api.agent
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import kotlinx.coroutines.flow.Flow
/**
* Routes each [BaseEvent] to the matching [AgentSubscriber] callback.
*/
public object EventDispatcher {
/** Dispatches a single [event] to [subscriber]: always [AgentSubscriber.onEvent], then the typed callback. */
public fun dispatch(event: BaseEvent, subscriber: AgentSubscriber) {
subscriber.onEvent(event)
when (event) {
is RunStartedEvent -> subscriber.onRunStarted(event)
is RunFinishedEvent -> subscriber.onRunFinished(event)
is RunErrorEvent -> subscriber.onRunError(event)
is StepStartedEvent -> subscriber.onStepStarted(event)
is StepFinishedEvent -> subscriber.onStepFinished(event)
is TextMessageStartEvent -> subscriber.onTextMessageStart(event)
is TextMessageContentEvent -> subscriber.onTextMessageContent(event)
is TextMessageEndEvent -> subscriber.onTextMessageEnd(event)
is ToolCallStartEvent -> subscriber.onToolCallStart(event)
is ToolCallArgsEvent -> subscriber.onToolCallArgs(event)
is ToolCallEndEvent -> subscriber.onToolCallEnd(event)
is ToolCallResultEvent -> subscriber.onToolCallResult(event)
is StateSnapshotEvent -> subscriber.onStateSnapshot(event)
is StateDeltaEvent -> subscriber.onStateDelta(event)
is MessagesSnapshotEvent -> subscriber.onMessagesSnapshot(event)
is RawEvent -> subscriber.onRaw(event)
is CustomEvent -> subscriber.onCustom(event)
}
}
}
/** Collects [this] flow and dispatches each event to [subscriber]. */
public suspend fun Flow<BaseEvent>.dispatchTo(subscriber: AgentSubscriber) {
collect { event -> EventDispatcher.dispatch(event, subscriber) }
}
@@ -0,0 +1,14 @@
package com.agui.api.context
import kotlinx.serialization.Serializable
/**
* A single piece of context supplied for a run. Mirrors the AG-UI `Context` type.
*
* [description] is a human-readable label; [value] carries the opaque payload.
*/
@Serializable
public data class Context(
val description: String,
val value: String,
)
@@ -0,0 +1,172 @@
package com.agui.api.event
import com.agui.api.message.Message
import com.agui.api.message.MessageRole
import com.agui.api.state.State
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Root of the AG-UI event stream.
*
* AG-UI is a push protocol: an agent streams [BaseEvent]s to a client. The concrete
* subclasses mirror the AG-UI protocol event catalogue and are (de)serialized
* polymorphically by the `type` discriminator field.
*/
public sealed interface BaseEvent {
/** Event creation timestamp in epoch milliseconds, if provided. */
public val timestamp: Long?
}
// region Lifecycle
@Serializable
@SerialName("RUN_STARTED")
public data class RunStartedEvent(
val threadId: String,
val runId: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("RUN_FINISHED")
public data class RunFinishedEvent(
val threadId: String? = null,
val runId: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("RUN_ERROR")
public data class RunErrorEvent(
val message: String,
val code: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STEP_STARTED")
public data class StepStartedEvent(
val stepName: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STEP_FINISHED")
public data class StepFinishedEvent(
val stepName: String,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Text message
@Serializable
@SerialName("TEXT_MESSAGE_START")
public data class TextMessageStartEvent(
val messageId: String,
val role: MessageRole = MessageRole.ASSISTANT,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TEXT_MESSAGE_CONTENT")
public data class TextMessageContentEvent(
val messageId: String,
val delta: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TEXT_MESSAGE_END")
public data class TextMessageEndEvent(
val messageId: String,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Tool call
@Serializable
@SerialName("TOOL_CALL_START")
public data class ToolCallStartEvent(
val toolCallId: String,
val toolCallName: String,
val parentMessageId: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_ARGS")
public data class ToolCallArgsEvent(
val toolCallId: String,
val delta: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_END")
public data class ToolCallEndEvent(
val toolCallId: String,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("TOOL_CALL_RESULT")
public data class ToolCallResultEvent(
val toolCallId: String,
val messageId: String? = null,
val role: MessageRole = MessageRole.TOOL,
val content: String? = null,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region State / messages
@Serializable
@SerialName("STATE_SNAPSHOT")
public data class StateSnapshotEvent(
val snapshot: State,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("STATE_DELTA")
public data class StateDeltaEvent(
val delta: JsonElement,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("MESSAGES_SNAPSHOT")
public data class MessagesSnapshotEvent(
val messages: List<Message>,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
// region Special
@Serializable
@SerialName("RAW")
public data class RawEvent(
val event: JsonElement,
override val timestamp: Long? = null,
) : BaseEvent
@Serializable
@SerialName("CUSTOM")
public data class CustomEvent(
val name: String,
val value: JsonElement? = null,
override val timestamp: Long? = null,
) : BaseEvent
// endregion
@@ -0,0 +1,48 @@
package com.agui.api.message
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A message role. Serialized using the AG-UI wire value (e.g. `"assistant"`, `"tool"`).
*/
@Serializable
public enum class MessageRole {
@SerialName("developer") DEVELOPER,
@SerialName("system") SYSTEM,
@SerialName("assistant") ASSISTANT,
@SerialName("user") USER,
@SerialName("tool") TOOL,
}
/**
* A single chat message. Mirrors the AG-UI `Message` type, which is a base message
* optionally carrying tool calls (assistant) or a tool result id (tool).
*/
@Serializable
public data class Message(
val id: String,
val role: MessageRole,
val content: String = "",
val toolCalls: List<ToolCall> = emptyList(),
val toolCallId: String? = null,
)
/** A tool invocation requested by an assistant message. */
@Serializable
public data class ToolCall(
val id: String,
val type: String = "function",
val function: ToolFunction = ToolFunction(),
)
/** The function part of a [ToolCall]. */
@Serializable
public data class ToolFunction(
val name: String = "",
val arguments: String = "{}",
)
@@ -0,0 +1,66 @@
package com.agui.api.run
import com.agui.api.context.Context
import com.agui.api.message.Message
import com.agui.api.state.State
import com.agui.api.tool.Tool
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
/**
* The complete request payload of an AG-UI run.
*
* This is exactly what a client POSTs to an agent and what a server receives.
*/
@Serializable
public data class RunAgentInput(
val threadId: String,
val runId: String,
val state: State = emptyMap(),
val messages: List<Message> = emptyList(),
val tools: List<Tool> = emptyList(),
val context: List<Context> = emptyList(),
val forwardedProps: Map<String, JsonElement> = emptyMap(),
)
/**
* Parameters a *caller* supplies for a single run, before it is turned into a
* [RunAgentInput]. Unlike [RunAgentInput] it carries no thread/identity — the agent fills
* those in.
*/
public data class RunAgentParameters(
val context: List<Context> = emptyList(),
val tools: List<Tool> = emptyList(),
val messages: List<Message> = emptyList(),
val forwardedProps: Map<String, JsonElement> = emptyMap(),
val runId: String? = null,
)
/** Builds a [RunAgentInput] from [parameters] plus an explicit thread id, run id and state. */
public fun RunAgentParameters.toRunAgentInput(
threadId: String,
runId: String = this.runId ?: randomRunId(),
state: State = emptyMap(),
): RunAgentInput = RunAgentInput(
threadId = threadId,
runId = runId,
state = state,
messages = messages,
tools = tools,
context = context,
forwardedProps = forwardedProps,
)
/** The inverse of [toRunAgentInput]: projects a [RunAgentInput] back to caller parameters. */
public fun RunAgentInput.toParameters(): RunAgentParameters = RunAgentParameters(
context = context,
tools = tools,
messages = messages,
forwardedProps = forwardedProps,
runId = runId,
)
/** Generates a reasonably-unique run identifier, suitable for the [RunAgentInput.runId]. */
public fun randomRunId(prefix: String = "run"): String =
prefix + "-" + Random.nextLong().toULong().toString(16)
@@ -0,0 +1,66 @@
package com.agui.api.serialization
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
/**
* Serializers module registering every [BaseEvent] subtype for polymorphic
* (de)serialization. A `sealed interface` does not auto-register its subtypes,
* so they are declared explicitly here.
*/
public val AguiEventsSerializersModule: SerializersModule = SerializersModule {
polymorphic(BaseEvent::class) {
subclass(RunStartedEvent::class, RunStartedEvent.serializer())
subclass(RunFinishedEvent::class, RunFinishedEvent.serializer())
subclass(RunErrorEvent::class, RunErrorEvent.serializer())
subclass(StepStartedEvent::class, StepStartedEvent.serializer())
subclass(StepFinishedEvent::class, StepFinishedEvent.serializer())
subclass(TextMessageStartEvent::class, TextMessageStartEvent.serializer())
subclass(TextMessageContentEvent::class, TextMessageContentEvent.serializer())
subclass(TextMessageEndEvent::class, TextMessageEndEvent.serializer())
subclass(ToolCallStartEvent::class, ToolCallStartEvent.serializer())
subclass(ToolCallArgsEvent::class, ToolCallArgsEvent.serializer())
subclass(ToolCallEndEvent::class, ToolCallEndEvent.serializer())
subclass(ToolCallResultEvent::class, ToolCallResultEvent.serializer())
subclass(StateSnapshotEvent::class, StateSnapshotEvent.serializer())
subclass(StateDeltaEvent::class, StateDeltaEvent.serializer())
subclass(MessagesSnapshotEvent::class, MessagesSnapshotEvent.serializer())
subclass(RawEvent::class, RawEvent.serializer())
subclass(CustomEvent::class, CustomEvent.serializer())
}
}
/**
* The canonical [Json] configuration used to (de)serialize the AG-UI wire protocol.
*
* - Polymorphic [BaseEvent]s are (de)serialized by their `type` discriminator
* field, with every subtype registered in [AguiEventsSerializersModule].
* - Unknown keys are ignored, so newer protocol versions stay backward compatible.
* - Null fields are omitted to keep the wire payload compact.
*/
public val AguiJson: Json = Json {
serializersModule = AguiEventsSerializersModule
classDiscriminator = "type"
ignoreUnknownKeys = true
explicitNulls = false
}
@@ -0,0 +1,19 @@
package com.agui.api.state
import kotlinx.serialization.json.JsonElement
/**
* A loosely-typed, platform-agnostic bag of agent state carried in AG-UI
* `STATE_SNAPSHOT`/`STATE_DELTA` events and in [com.agui.api.run.RunAgentInput.state].
*
* Because the value is a JSON element, it can be any JSON scalar, object or array — the
* protocol does not prescribe a concrete schema.
*/
public typealias State = Map<String, JsonElement>
/** Mutable form of [State] used while an agent builds up its own state. */
public typealias MutableState = MutableMap<String, JsonElement>
/** Builds a [MutableState] from the given `key to json` pairs. */
public fun mutableState(vararg pairs: Pair<String, JsonElement>): MutableState =
if (pairs.isEmpty()) linkedMapOf() else linkedMapOf(*pairs)
@@ -0,0 +1,17 @@
package com.agui.api.tool
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* A tool the agent can call during a run. Mirrors the AG-UI `Tool` type.
*
* [parameters] is a JSON Schema object describing the tool's arguments; it is kept as a
* [JsonObject] so any schema shape can be carried verbatim.
*/
@Serializable
public data class Tool(
val name: String,
val description: String = "",
val parameters: JsonObject = JsonObject(emptyMap()),
)
@@ -0,0 +1,91 @@
package com.agui.api
import com.agui.api.agent.AbstractAgent
import com.agui.api.agent.AgentSubscriber
import com.agui.api.agent.EventDispatcher
import com.agui.api.agent.dispatchTo
import com.agui.api.context.Context
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.run.RunAgentInput
import com.agui.api.run.RunAgentParameters
import com.agui.api.run.randomRunId
import com.agui.api.run.toParameters
import com.agui.api.run.toRunAgentInput
import com.agui.api.tool.Tool
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AgentTest {
@Test
fun `RunAgentParameters convert to RunAgentInput and back`() {
val params = RunAgentParameters(
runId = "r1",
context = listOf(Context(description = "c", value = "v")),
tools = listOf(Tool(name = "t")),
)
val input = params.toRunAgentInput(threadId = "t1")
assertEquals("t1", input.threadId)
assertEquals("r1", input.runId)
assertEquals(listOf(Context(description = "c", value = "v")), input.context)
assertEquals(params, input.toParameters())
}
@Test
fun `randomRunId is prefixed and non-empty`() {
val id = randomRunId()
val (prefix, rest) = id.split('-', limit = 2)
assertEquals("run", prefix)
assertTrue(rest.isNotEmpty(), "id should have a suffix, got: $id")
}
@Test
fun `EventDispatcher routes to the matching subscriber callback`() = runTest {
val seen = mutableListOf<String>()
val sub = object : AgentSubscriber {
override fun onRunStarted(event: RunStartedEvent) {
seen += "started"
}
override fun onCustom(event: CustomEvent) {
seen += "custom"
}
}
flowOf<BaseEvent>(
RunStartedEvent(threadId = "t1", runId = "r1"),
CustomEvent(name = "x"),
RunStartedEvent(threadId = "t1", runId = "r1"),
).dispatchTo(sub)
assertEquals(listOf("started", "custom", "started"), seen)
}
@Test
fun `onEvent is called for every dispatched event`() {
var count = 0
val sub = object : AgentSubscriber {
override fun onEvent(event: BaseEvent) {
count++
}
}
EventDispatcher.dispatch(RunStartedEvent(threadId = "t1", runId = "r1"), sub)
EventDispatcher.dispatch(CustomEvent(name = "x"), sub)
assertEquals(2, count)
}
@Test
fun `AbstractAgent binds a run to its own identity`() = runTest {
val agent = object : AbstractAgent(agentId = "calculator", threadId = "thread-1") {
override fun runAgent(parameters: RunAgentParameters) = flowOf(
RunStartedEvent(threadId = threadId, runId = parameters.runId ?: "?"),
)
}
val events = agent.run(RunAgentInput(threadId = "other-thread", runId = "r9")).toList()
assertEquals(RunStartedEvent(threadId = "thread-1", runId = "r9"), events.single())
}
}
@@ -0,0 +1,87 @@
package com.agui.api
import com.agui.api.event.BaseEvent
import com.agui.api.event.CustomEvent
import com.agui.api.event.MessagesSnapshotEvent
import com.agui.api.event.RawEvent
import com.agui.api.event.RunErrorEvent
import com.agui.api.event.RunFinishedEvent
import com.agui.api.event.RunStartedEvent
import com.agui.api.event.StateDeltaEvent
import com.agui.api.event.StateSnapshotEvent
import com.agui.api.event.StepFinishedEvent
import com.agui.api.event.StepStartedEvent
import com.agui.api.event.TextMessageContentEvent
import com.agui.api.event.TextMessageEndEvent
import com.agui.api.event.TextMessageStartEvent
import com.agui.api.event.ToolCallArgsEvent
import com.agui.api.event.ToolCallEndEvent
import com.agui.api.event.ToolCallResultEvent
import com.agui.api.event.ToolCallStartEvent
import com.agui.api.message.Message
import com.agui.api.message.MessageRole
import com.agui.api.serialization.AguiJson
import com.agui.api.state.mutableState
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EventSerializationTest {
private val events: List<BaseEvent> = listOf(
RunStartedEvent(threadId = "t1", runId = "r1", timestamp = 1L),
RunFinishedEvent(threadId = "t1", runId = "r1"),
RunErrorEvent(message = "boom", code = "E1"),
StepStartedEvent(stepName = "step1"),
StepFinishedEvent(stepName = "step1"),
TextMessageStartEvent(messageId = "m1", role = MessageRole.USER),
TextMessageContentEvent(messageId = "m1", delta = "hello"),
TextMessageEndEvent(messageId = "m1"),
ToolCallStartEvent(toolCallId = "tc1", toolCallName = "getWeather", parentMessageId = "m1"),
ToolCallArgsEvent(toolCallId = "tc1", delta = """{"city":""""),
ToolCallEndEvent(toolCallId = "tc1"),
ToolCallResultEvent(toolCallId = "tc1", messageId = "r1", content = "20C"),
StateSnapshotEvent(snapshot = mutableState("a" to JsonPrimitive(1))),
StateDeltaEvent(delta = buildJsonObject { put("a", 2) }),
MessagesSnapshotEvent(messages = listOf(Message(id = "m0", role = MessageRole.ASSISTANT, content = "hi"))),
RawEvent(event = buildJsonObject { put("anything", true) }),
CustomEvent(name = "my.custom", value = buildJsonArray { add(JsonPrimitive("x")) }),
)
@Test
fun `every event round-trips through the wire JSON`() {
for (event in events) {
val json = AguiJson.encodeToString<BaseEvent>(event)
val decoded = AguiJson.decodeFromString<BaseEvent>(json)
assertEquals(event, decoded, "round-trip failed for $event")
}
}
@Test
fun `events are discriminated by their AG-UI type name`() {
val json = AguiJson.encodeToString<BaseEvent>(RunStartedEvent(threadId = "t1", runId = "r1"))
assertTrue(json.contains("\"type\":\"RUN_STARTED\""), json)
assertTrue(json.contains("\"threadId\":\"t1\""), json)
assertTrue(json.contains("\"runId\":\"r1\""), json)
}
@Test
fun `an AG-UI wire event decodes with defaults applied`() {
val wire = """{"type":"TEXT_MESSAGE_START","messageId":"m1"}"""
val event = AguiJson.decodeFromString<BaseEvent>(wire)
assertEquals(TextMessageStartEvent(messageId = "m1"), event)
}
@Test
fun `unknown fields are ignored for forward compatibility`() {
val wire = """{"type":"RUN_ERROR","message":"boom","failureDetails":{}}"""
val event = AguiJson.decodeFromString<BaseEvent>(wire)
assertEquals(RunErrorEvent(message = "boom"), event)
}
}