proto/server/client: Agent.info, Working-маркер хода, /record, таймауты запросов

- proto: Agent.info (AgentInfo: name/description/usefulness) — человекочитаемое
  имя отдельно от opaque id; сериализация и тесты
- server: GET {path} отдаёт Agent.info; GET /conversations/{id}/record —
  ConversationRecord без handle'а (для клиентского кэша после AgentEvent.Created)
- outbox: Event.Working — первый event хода, эмитится из Conversation.send()
  до LLM-цикла и turnLock, чтобы UI показал спиннер сразу
- standalone: AGENTIK_NAME/AGENTIK_DESCRIPTION/AGENTIK_USEFULNESS → AgentInfo
- client: AgentClient.create eagerly фетчит info (GET {baseUrl})
- client: HttpConversationStore.get читает /record (ConversationRecord,
  а не ConversationSnapshot — рассинхрон типов)
- client: noReadTimeout() на POST /conversations/{id}/messages — сервер отвечает
  по завершении всего хода агента (реально 0.5–144 с), дефолтные 15 с рвали
  живую реплику на клиенте
- journal-api: ConversationRecord @Serializable
- ksqlite 0.1.3 → 0.1.4
- .gitignore: runtime-данные standalone-агента и hs_err-дампы
This commit is contained in:
2026-09-28 10:09:34 +03:00
parent 4156e5f95f
commit 509763cf36
24 changed files with 681 additions and 16 deletions
@@ -33,6 +33,19 @@ internal fun Route.agentikRoutes(agent: Agent) {
call.respondText("ok")
}
/**
* `GET {path}` — публичная информация об агенте (`name` +
* опциональные `description` / `usefulness`). Контрактно отличается
* от `/health` тем, что отдаёт человекочитаемые данные об агенте,
* а не просто "alive"-сигнал.
*
* Используется UI-дашбордами / A2A-fetch'ерами AgentCard для
* отображения карточки агента без подписки на события.
*/
get("") {
call.respond(agent.info)
}
// ---- Agent: множество диалогов ----
get("/conversations") {
@@ -54,6 +67,25 @@ internal fun Route.agentikRoutes(agent: Agent) {
else call.respond(c.snapshot())
}
/**
* `GET /conversations/{id}/record` — лёгкая метадата записи
* ([pw.binom.agentik.journal.ConversationRecord]: id/title/isTemporal/
* createdAt/updatedAt) БЕЗ handle'а и image-support флагов.
*
* Отдельно от `GET /conversations/{id}` (который отдаёт
* [pw.binom.agentik.proto.ConversationSnapshot] — его ждёт
* `AgentClient.getConversation`): это тот же shape, что и элемент
* списка `GET /conversations`, его читает клиентский кэш
* (`HttpConversationStore.get`), чтобы дотянуть запись после
* outbox-события `AgentEvent.Created`.
*/
get("/conversations/{id}/record") {
val id = call.parameters["id"]!!
val rec = agent.conversationStore.get(id)
if (rec == null) call.respond(HttpStatusCode.NotFound)
else call.respond(rec)
}
delete("/conversations/{id}") {
val id = call.parameters["id"]!!
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
@@ -0,0 +1,169 @@
package pw.binom.agentik.server
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
/**
* Интеграционные тесты endpoint'а `GET {path}` (без суффикса) — отдаёт
* публичную информацию об агенте ([AgentInfo]) для UI/A2A/IRC.
*
* Проверяет:
* - happy path с полным [AgentInfo] (name + description + usefulness);
* - минимальный [AgentInfo] (только name, optional поля null/отсутствуют);
* - JSON-shape: только присутствующие в [AgentInfo] поля, `null`-опциональные
* выкинуты (благодаря `explicitNulls = false` в `agentikJson`);
* - endpoint доступен без авторизации (auth отключён в этом тесте;
* bearer-token тест уже в [BearerTokenTest]).
*/
class AgentInfoRouteTest {
private lateinit var server: EmbeddedServer<*, *>
private var port: Int = 0
@Serializable
private data class InfoDto(val name: String, val description: String? = null, val usefulness: String? = null)
@BeforeTest
fun setup() {
val fake = object : Agent {
override val id: String = "test-agent"
override val info: AgentInfo = AgentInfo(
name = "Hermes",
description = "Kotlin-ассистент для agentik",
usefulness = "Помогает писать код и искать баги",
)
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
}
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String) = null
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {}
}
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
routing { agentikAgent(fake, path = "/agentik", token = null) }
}.start(wait = false)
port = runBlocking { server.engine.resolvedConnectors()[0].port }
}
@AfterTest
fun tearDown() {
server.stop(100, 200)
}
private fun client(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
@Test
fun get_returns_full_info_as_json() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik")
assertEquals(HttpStatusCode.OK, response.status)
val dto = response.body<InfoDto>()
assertEquals(InfoDto("Hermes", "Kotlin-ассистент для agentik", "Помогает писать код и искать баги"), dto)
}
@Test
fun get_body_shape_has_all_three_fields() = runBlocking {
val response = client().get("http://127.0.0.1:$port/agentik")
val raw = response.bodyAsText()
// explicitNulls=false → все три поля всегда присутствуют у full-info agent'а.
assertEquals(
"""{"name":"Hermes","description":"Kotlin-ассистент для agentik","usefulness":"Помогает писать код и искать баги"}""",
raw,
)
}
@Test
fun get_returns_minimal_info_when_only_name_set() {
// override agent: minimal info (только name).
// Storage handles — noop-stub'ы (см. BearerTokenTest: agentikAgent
// вычитывает их eagerly при старте).
val minimal = object : Agent {
override val id: String = "minimal"
override val info: AgentInfo = AgentInfo(name = "agentik")
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
override suspend fun count(conversationId: String): Long = 0L
override suspend fun count(conversationId: String, after: Instant): Long = 0L
override fun listFlow(conversationId: String, after: Instant, pageSize: Int) = emptyFlow<MessageRecord>()
override fun close() {}
}
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) = emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
}
override val conversationStore: ConversationStore = object : ConversationStore {
override suspend fun get(id: String) = null
override suspend fun list(offset: Int, limit: Int) = emptyList<pw.binom.agentik.journal.ConversationRecord>()
override fun close() {}
}
override fun createConversation(temp: Boolean): Conversation = TODO("not used")
override suspend fun getConversation(id: String): Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {}
}
val server2 = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
routing { agentikAgent(minimal, path = "/agentik", token = null) }
}.start(wait = false)
try {
val p = runBlocking { server2.engine.resolvedConnectors()[0].port }
val raw = runBlocking { client().get("http://127.0.0.1:$p/agentik").bodyAsText() }
// explicitNulls=false выкидывает null-опциональные поля → только {"name":"agentik"}.
assertEquals("""{"name":"agentik"}""", raw)
} finally {
server2.stop(100, 200)
}
}
}
@@ -19,6 +19,7 @@ import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
import kotlin.test.Test
@@ -41,6 +42,7 @@ class BearerTokenTest {
*/
private class FakeAgent(
override val id: String = "test",
override val info: AgentInfo = AgentInfo(name = "test"),
) : Agent {
override val journal: JournalStore = object : JournalStore {
override suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int) = emptyList<MessageRecord>()
@@ -0,0 +1,181 @@
package pw.binom.agentik.server
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.cio.CIO as ServerCIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Тесты `GET /conversations` и `GET /conversations/{id}/record` — обе отдают
* [ConversationRecord] (лёгкую метадату элемента списка).
*
* Это **регрессия** на два бага, из-за которых клиентский
* `wrapWithLocalConversationCache` не наполнялся:
* 1. [ConversationRecord] не был `@Serializable` → `GET /conversations`
* падал 500 `Serializer for class ... not found`;
* 2. клиентский `HttpConversationStore.get` читал `ConversationRecord` из
* `GET /conversations/{id}`, который отдаёт `ConversationSnapshot`
* (другой shape: handle + isImageSupported, без createdAt/updatedAt)
* → `MissingFieldException: Field 'createdAt' is required`. Для этого
* заведён отдельный `/record`-endpoint.
*/
class ConversationRoutesTest {
private lateinit var server: EmbeddedServer<*, *>
private lateinit var journal: InMemoryJournalStore
private lateinit var store: InMemoryMutableConversationStore
private var port: Int = 0
private val t0 = Instant.parse("2026-09-22T10:00:00Z")
@BeforeTest
fun setup() {
journal = InMemoryJournalStore()
store = InMemoryMutableConversationStore()
val fakeAgent = FakeAgent(journal, store)
server = embeddedServer(ServerCIO, port = 0, host = "127.0.0.1") {
routing { agentikAgent(fakeAgent, path = "/agentik", token = null) }
}.start(wait = false)
port = runBlocking { server.engine.resolvedConnectors()[0].port }
}
@AfterTest
fun tearDown() {
server.stop(100, 200)
journal.close()
store.close()
}
private fun client(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(agentikJson) }
}
private fun seed(id: String, title: String?, isTemporal: Boolean = false, updatedAt: Instant = t0) =
runBlocking {
store.upsert(ConversationRecord(id, title, isTemporal, t0, updatedAt))
}
@Test
fun `list returns serializable conversation records`() = runBlocking {
seed("c1", "Первый", updatedAt = t0)
seed("c2", null, isTemporal = true, updatedAt = t0 + 1.seconds)
val client = client()
try {
val resp = client.get("http://127.0.0.1:$port/agentik/conversations")
assertEquals(HttpStatusCode.OK, resp.status)
// До фикса `@Serializable` здесь был 500.
val records = resp.body<List<ConversationRecord>>()
// Сортировка хранилища — updatedAt DESC.
assertEquals(listOf("c2", "c1"), records.map { it.id })
assertEquals(listOf(null, "Первый"), records.map { it.title })
assertEquals(Instant.parse("2026-09-22T10:00:01Z"), records[0].updatedAt)
} finally {
client.close()
}
}
@Test
fun `list body carries timestamps that the client cache needs`() = runBlocking {
seed("c1", "Первый")
val client = client()
try {
val raw = client.get("http://127.0.0.1:$port/agentik/conversations").bodyAsText()
assertEquals(
"""[{"id":"c1","title":"Первый","isTemporal":false,""" +
""""createdAt":"2026-09-22T10:00:00Z","updatedAt":"2026-09-22T10:00:00Z"}]""",
raw,
)
} finally {
client.close()
}
}
@Test
fun `record returns conversation record by id`() = runBlocking {
seed("c1", "Первый", isTemporal = true)
val client = client()
try {
val resp = client.get("http://127.0.0.1:$port/agentik/conversations/c1/record")
assertEquals(HttpStatusCode.OK, resp.status)
val rec = assertNotNull(resp.body<ConversationRecord>())
assertEquals("c1", rec.id)
assertEquals("Первый", rec.title)
assertEquals(true, rec.isTemporal)
assertEquals(t0, rec.createdAt)
} finally {
client.close()
}
}
@Test
fun `record for unknown conversation returns not found`() = runBlocking {
val client = client()
try {
val resp = client.get("http://127.0.0.1:$port/agentik/conversations/never-existed/record")
assertEquals(HttpStatusCode.NotFound, resp.status)
} finally {
client.close()
}
}
/**
* Минимальный stub-агент: реальные [JournalStore] и
* [InMemoryMutableConversationStore]; outbox — noop, conversation-хендлы
* (`getConversation`/`createConversation`) тесту не нужны.
*/
private class FakeAgent(
private val js: JournalStore,
private val cs: ConversationStore,
) : Agent {
override val id: String = "test"
override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()
override fun agentEvents(after: Instant?) = emptyFlow<CommonEvent.Agent>()
override fun conversationEvents(after: Instant?, conversationId: String?) =
emptyFlow<CommonEvent.Conversation>()
override suspend fun earliestEventDate(): Instant = Instant.DISTANT_PAST
override fun close() {}
}
override val conversationStore: ConversationStore = cs
override fun createConversation(temp: Boolean): pw.binom.agentik.proto.Conversation =
TODO("not used")
override suspend fun getConversation(id: String): pw.binom.agentik.proto.Conversation? = null
override suspend fun deleteConversation(id: String): Boolean = false
override suspend fun renameConversation(id: String, title: String?): Instant? = null
override fun close() {}
}
}
@@ -26,6 +26,7 @@ import pw.binom.agentik.journal.inmemory.InMemoryJournalStore
import pw.binom.agentik.outbox.CommonEvent
import pw.binom.agentik.outbox.OutboxStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentInfo
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -177,6 +178,7 @@ class JournalRoutesCountTest {
*/
private class FakeAgent(private val js: JournalStore) : Agent {
override val id: String = "test"
override val info: AgentInfo = AgentInfo(name = "test")
override val journal: JournalStore = js
override val outbox: OutboxStore = object : OutboxStore {
override fun events(after: Instant?) = emptyFlow<CommonEvent>()