standalone: persist turn errors so polling clients see them

Event.Error уже эмитился в live-SSE, но если клиент подключился
после провала хода (или опрашивает историю через getMessages
вместо SSE), он видел только user-сообщение без следа, что ход
провалился. Это и нужно было поправить.

* :proto
  - Message.Error(id, message, code?, date) — терминальная
    персистентная проекция Event.Error. Audit-only; в live-стриме
    по-прежнему приходит Event.Error.

* :standalone
  - MessageRecord.Error с тем же контрактом.
  - SqliteMessageStore: kind "error", JSON-payload {message, code};
    encoding-ошибки round-trip покрыты тестом (с code и без).
  - ChatConversation.failTurn(message, code?): пишет MessageRecord.Error
    в audit (кроме temp-диалогов), затем эмитит Event.Error + Event.End.
    Все три exit-точки из runTurn (пустой parts, init-catch,
    stream-catch) теперь через failTurn.
  - ChatConversation: при ошибке стрима живой LiteConversation
    сбрасывается — следующий send пересоберёт его из working_memory.
    Раньше оставляли битую инстанцию вопреки KDoc класса.
  - toProto: маппит MessageRecord.Error → Message.Error.

* docs
  - STANDALONE.md: добавлен Message.Error в таблицу dual-log + абзац
    про персист ошибок (audit/working_memory, сброс liteConv).
  - ARCHITECTURE.md: Message.Error в сигнатуре Message, failTurn
    в ChatConversation, Message.Error в audit log.
  - Поправлен пример describe() в доке тулов (реальный формат —
    OpenAI-style function wrapper, а не голый JSON Schema).

Тесты:
  - ChatAgentTest: LLM failure → Event.Error + Event.End + Error
    record в audit + backfill через getMessages.
  - PersistenceTest: MessageRecord.Error round-trip (с code и без).
  - :standalone jvmTest 70 (было 69).

E2E: неверный model id (HTTP 400) → polling GET /messages теперь
возвращает user_message + error (без assistant_message).
This commit is contained in:
2026-09-14 00:26:13 +03:00
parent adcb8f54d6
commit 0faad45f3d
8 changed files with 156 additions and 12 deletions
@@ -39,4 +39,19 @@ sealed interface Message {
@Serializable
@SerialName("tool_result")
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message
/**
* Ход завершился ошибкой (LLM, инициализация движка или иная отказоустойчивая
* ситуация). Терминальная запись хода: соответствующего [AssistantMessage]
* не будет. В live-стриме приходит [Event.Error]; этот тип — его персистентная
* проекция, видимая при backfill через `getMessages`.
*/
@Serializable
@SerialName("error")
class Error(
override val id: String,
val message: String,
val code: String? = null,
override val date: Instant,
) : Message
}