feat(agentik-cli): one-shot subcommand CLI; client: streaming SSE via prepareGet
ci / JVM build + tests (push) Failing after 2m11s

- :agentik-cli переписан с REPL на one-shot subcommands:
  conv-ls / conv-new / conv-show / conv-delete / conv-rename /
  msgs / send / interrupt / info. Аргумент-парсер — kotlinx.cli 0.3.6
  (clikt 5.x отвергнут из-за upstream-бага duplicate symbol
  selfAndAncestors между clikt и clikt-mordant, issue #598).
- :client: events() переведён с httpClient.get() на
  prepareGet()+execute{} — get() дожидается полного тела, а SSE
  не закрывается никогда, поэтому подписка висела вечно. (Это
  же объясняет, почему TUI agent.events() фактически был
  нерабочим на реальном сервере.)
- :client KMP-конверсия (jvm + 5 desktop-native) уже была в
  коммите 9d826a4, здесь она просто подтверждена в статусе
  green по всем таргетам.
- REPL-инфраструктура (CliPlatform, EventRenderer, Main,
  SessionRepository, SlashCommand + 3 теста) удалена.
- agentik-cli/README переписан под subcommand-формат,
  root README обновлён (убран дубликат строки, agentik-tui
  убран из 'Запускаемые модули').

Smoke (на 192.168.76.166): info / conv-ls / conv-new /
conv-rename / conv-show / conv-delete / msgs / send
(стримит response-events до event End) / interrupt
(выводит event Interrupted).
This commit is contained in:
2026-09-17 23:05:25 +03:00
parent ee0b9d8341
commit b5b21d146a
37 changed files with 595 additions and 1427 deletions
@@ -1,323 +1,87 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
import kotlinx.cli.ArgParser
import kotlinx.cli.ArgType
import kotlinx.cli.ExperimentalCli
import kotlinx.cli.Subcommand
import kotlinx.cli.default
import pw.binom.agentik.cli.commands.ConvDeleteSubcommand
import pw.binom.agentik.cli.commands.ConvLsSubcommand
import pw.binom.agentik.cli.commands.ConvNewSubcommand
import pw.binom.agentik.cli.commands.ConvRenameSubcommand
import pw.binom.agentik.cli.commands.ConvShowSubcommand
import pw.binom.agentik.cli.commands.InfoSubcommand
import pw.binom.agentik.cli.commands.InterruptSubcommand
import pw.binom.agentik.cli.commands.MsgsSubcommand
import pw.binom.agentik.cli.commands.SendSubcommand
/**
* Главный класс REPL.
* Default `server` URL: env `AGENTIK_SERVER` или `http://localhost:8080/agentik`.
* Default `agent id`: env `AGENTIK_AGENT_ID` или `cli`.
*
* Управляет:
* - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]);
* - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer].
* - персистентностью сессии (восстановление последнего диалога при перезапуске CLI).
*
* Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения.
* `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий
* увидит [Event.Interrupted] и сам завершится.
* Используется в `runAgentikCli` и в каждом subcommand'е для своего
* `--server`/`--id` (иначе subcommand не видит значения родителя).
*/
class AgentikCli internal constructor(private val config: CliConfig) {
internal fun defaultServerUrl(): String = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
internal fun defaultAgentId(): String = platformEnv("AGENTIK_AGENT_ID") ?: "cli"
private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id)
private val terminal: CliTerminal = CliPlatform.openTerminal(
historyFile = if (config.historyEnabled) stateFilePath() else null,
prompt = "agentik> ",
)
private val sessionRepo = SessionRepository(
filePath = if (config.historyEnabled) stateFilePath() else null,
io = CliPlatform.sessionIo(),
/**
* Корневой [ArgParser] `agentik-cli`. Один вызов — одна команда.
*
* ```
* agentik-cli <command> [args...]
*
* Commands:
* conv-ls список диалогов
* conv-new [--temp] создать диалог, печатает id
* conv-show <id> метаданные диалога
* conv-delete <id> удалить диалог
* conv-rename <id> <title> переименовать
* msgs <id> [--limit N] показать сообщения
* send <id> <text...> отправить ход, стримит response-события в stdout
* interrupt <id> прервать текущий ход
* info показать конфиг
*
* `--server` и `--id` задаются ПОСЛЕ имени subcommand'а (т.е.
* `agentik-cli conv-ls --server http://...`), не до — kotlinx.cli не
* шарит опции родителя в subcommand.
*/
@OptIn(ExperimentalCli::class)
fun runAgentikCli(args: Array<String>) {
val parser = ArgParser(programName = "agentik-cli")
val subcommands: List<Subcommand> = listOf(
ConvLsSubcommand(),
ConvNewSubcommand(),
ConvShowSubcommand(),
ConvDeleteSubcommand(),
ConvRenameSubcommand(),
MsgsSubcommand(),
SendSubcommand(),
InterruptSubcommand(),
InfoSubcommand(),
)
subcommands.forEach { parser.subcommands(it) }
private var currentConv: Conversation? = null
private var currentTitle: String? = null
private var lastEventAt: Instant = Instant.DISTANT_PAST
private val scope = CoroutineScope(Dispatchers.Default)
suspend fun run() {
try {
// Восстановление сессии.
val saved = sessionRepo.load()
if (saved != null) {
val conv = runCatching { agent.getConversation(saved.conversationId) }
.getOrNull()
if (conv != null) {
currentConv = conv
currentTitle = conv.title
lastEventAt = saved.lastEventAt
terminal.printSystem(
"восстановлен диалог ${shorten(conv.id)}" +
" (${conv.title ?: "без названия"})",
)
} else {
terminal.printSystem(
"прошлый диалог ${shorten(saved.conversationId)} больше не существует",
)
}
}
printBanner()
// Главный цикл.
while (scope.isActive) {
terminal.print(prompt())
val line = terminal.readLine() ?: break // EOF → выходим
val trimmed = line.trim()
if (trimmed.isEmpty()) continue
if (trimmed.startsWith("/")) {
when (val r = parseSlash(trimmed.substring(1))) {
is ParseResult.Success -> {
if (handleCommand(r.command) == CommandResult.Exit) break
}
is ParseResult.Failure -> terminal.printSystem(r.message)
}
} else {
handleUserMessage(trimmed)
}
}
} finally {
terminal.printSystem("до свидания.")
currentConv?.close()
terminal.close()
sessionRepo.close()
scope.cancel()
}
}
// ============================================================ banner / prompt
private suspend fun printBanner() {
terminal.println()
terminal.println("agentik-cli — id=${config.id} — type /help")
terminal.println("server: ${config.server}")
when (val c = currentConv) {
null -> terminal.println("диалог: не выбран — начните с /new или /switch <id>")
else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})")
}
terminal.println()
}
private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> "
private suspend fun printHelp() {
terminal.println(
"""
|Slash-команды:
| /help эта справка
| /new [title] создать новый диалог
| /list, /ls список диалогов (новые сверху)
| /switch <id>, /sw переключиться на диалог по id
| /rename <title> переименовать текущий диалог
| /delete [<id>], /rm удалить диалог (по id или текущий)
| /history, /h последние сообщения текущего диалога
| /interrupt, /stop прервать текущий ход
| /pwd показать текущий диалог
| /exit, /quit выйти (Ctrl-D тоже)
|
|Любой ввод без ведущего `/` отправляется агенту в текущий диалог.
""".trimMargin(),
)
}
// ============================================================ command dispatch
private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) {
SlashCommand.Help -> { printHelp(); CommandResult.Continue }
SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit
is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue }
SlashCommand.List -> { handleList(); CommandResult.Continue }
is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue }
is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue }
is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue }
SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue }
SlashCommand.History -> { handleHistory(); CommandResult.Continue }
SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue }
}
private suspend fun handleNew(title: String?) {
val conv = agent.createConversation(temp = false)
if (title != null) conv.rename(title)
currentConv = conv
currentTitle = title ?: conv.title
lastEventAt = Instant.DISTANT_PAST
terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "")
sessionRepo.save(conv.id, lastEventAt)
}
private suspend fun handleList() {
terminal.println("диалоги (новые сверху):")
agent.getConversations(offset = 0).collect { conv ->
val marker = if (conv.id == currentConv?.id) "*" else " "
val title = conv.title ?: "(без названия)"
terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]")
}
}
private suspend fun handleSwitch(id: String) {
val conv = agent.getConversation(id)
if (conv == null) {
terminal.printSystem("диалог $id не найден")
return
}
currentConv?.close()
currentConv = conv
currentTitle = conv.title
lastEventAt = Instant.DISTANT_PAST
sessionRepo.save(conv.id, lastEventAt)
terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})")
}
private suspend fun handleRename(title: String) {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога — /new")
return
}
c.rename(title)
currentTitle = title
terminal.printSystem("заголовок: $title")
}
private suspend fun handleDelete(id: String?) {
val target = id ?: currentConv?.id
if (target == null) {
terminal.printSystem("нет диалога для удаления")
return
}
val ok = agent.deleteConversation(target)
if (ok) {
terminal.printSystem("удалён ${shorten(target)}")
if (target == currentConv?.id) {
currentConv?.close()
currentConv = null
currentTitle = null
sessionRepo.clear()
}
} else {
terminal.printSystem("диалог ${shorten(target)} не найден")
}
}
private suspend fun handleInterrupt() {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога")
return
}
c.interrupt()
terminal.printSystem("прерывание отправлено")
}
private suspend fun handlePwd() {
val c = currentConv ?: run {
terminal.printSystem("диалог: не выбран")
return
}
terminal.printSystem("id: ${c.id}")
terminal.printSystem("title: ${c.title ?: "—"}")
terminal.printSystem("updatedAt: ${c.updatedAt}")
terminal.printSystem("temporal: ${c.isTemporal}")
}
private suspend fun handleHistory() {
val c = currentConv ?: run {
terminal.printSystem("нет активного диалога")
return
}
terminal.println("история:")
c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) }
}
private suspend fun renderHistoryMessage(msg: Message) {
val prefix = " [${msg.date}] "
when (msg) {
is Message.UserMessage ->
terminal.println(prefix + "user | " + msg.content.text())
is Message.AssistantMessage ->
terminal.println(prefix + "agent | " + msg.content.text())
is Message.ToolCall ->
terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}")
is Message.ToolResult ->
terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null"))
is Message.Error ->
terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}")
}
}
private fun List<Content>.text(): String =
joinToString(separator = "") { c ->
when (c) {
is Content.Text -> c.body
is Content.Image -> "[image:${c.mime}:${c.data.size}B]"
}
}
// ============================================================ user-message
private suspend fun handleUserMessage(text: String) {
val conv = currentConv ?: run {
terminal.printSystem("нет активного диалога — /new")
return
}
terminal.println() // пустая строка для визуального отделения блока
val renderer = EventRenderer(terminal)
val turnFinished = CompletableDeferred<Unit>()
// Подписчик events: принимает события и обновляет lastEventAt,
// по терминальному событию закрывает Deferred.
val eventsJob = scope.launch {
try {
conv.events(after = lastEventAt).collect { ev ->
renderer.render(ev)
if (ev.date > lastEventAt) {
lastEventAt = ev.date
sessionRepo.save(conv.id, lastEventAt)
}
if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) {
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
}
}
} catch (t: Throwable) {
if (!turnFinished.isCompleted) turnFinished.complete(Unit)
if (t !is kotlinx.coroutines.CancellationException) {
terminal.printSystem("[events stream error] ${t.message}")
}
}
}
try {
conv.send(listOf(Content.Text(text)))
turnFinished.await()
} catch (t: Throwable) {
terminal.printSystem("[send error] ${t.message}")
} finally {
eventsJob.cancel()
renderer.close()
terminal.println()
}
}
// ============================================================ utils
private fun shorten(id: String): String = id.take(8)
private fun stateFilePath(): String? {
val home = CliPlatform.homeDir() ?: return null
val dir = "$home/.agentik"
return "$dir/cli-state.json"
}
parser.parse(args)
}
private enum class CommandResult { Continue, Exit }
/**
* Базовый класс subcommand'а: каждый subcommand владеет своим `--server`/`--id`,
* чтобы значения родительских флагов были ему доступны (kotlinx.cli не шарит
* свойства родителя в subcommand).
*/
abstract class AgentikSubcommand(name: String, description: String) : Subcommand(name, description) {
val serverUrl: String by option(
ArgType.String, fullName = "server", shortName = "s",
description = "Base URL агента (env AGENTIK_SERVER)",
).default(defaultServerUrl())
val agentId: String by option(
ArgType.String, fullName = "id", shortName = "i",
description = "Идентификатор агента (env AGENTIK_AGENT_ID)",
).default(defaultAgentId())
}
fun main(args: Array<String>) {
runAgentikCli(args)
}
@@ -1,52 +0,0 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Agent
/**
* Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или
* нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент),
* предоставляются здесь как `expect/actual`.
*
* Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`),
* nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача).
*/
expect object CliPlatform {
fun openAgent(baseUrl: String, id: String): Agent
fun openTerminal(
historyFile: String?,
prompt: String,
): CliTerminal
/** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */
fun homeDir(): String?
/** Переменная среды (native API). Для jvmMain — `System.getenv`. */
fun env(key: String): String?
/** Файловое IO для session-state; nativeMain возвращает no-op. */
fun sessionIo(): SessionIo
}
/**
* Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать
* event-loop агентного цикла во время ожидания ввода.
*/
interface CliTerminal {
val prompt: String
/** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */
suspend fun readLine(): String?
/** Печатает строку + перевод строки. */
suspend fun println(text: String = "")
/** Печатает строку без перевода (для streamed chunks). */
suspend fun print(text: String)
/** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */
suspend fun printSystem(text: String)
/** Закрыть терминал: restore raw mode, flush history file, ... */
fun close()
}
@@ -1,82 +0,0 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Event
/**
* Печатает [Event] в человеко-читаемом виде через [CliTerminal].
*
* Дизайн:
* - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим
* отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]).
* - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие
* происходит при [Event.End] или [Event.Interrupted].
* - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки).
* - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder.
* Реальный рендеринг сделаем позже через iTerm/Kitty протоколы.
* - [Event.End] / [Event.Interrupted] — закрывают текущий блок.
* - [Event.Error] — отдельный системный блок `[error: …]`.
*/
class EventRenderer(private val terminal: CliTerminal) {
/** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */
private var responseOpen = false
suspend fun render(event: Event) {
when (event) {
is Event.StartReasoning -> {
terminal.printSystem("…thinking…")
if (responseOpen) {
terminal.println()
responseOpen = false
}
}
is Event.StartResponse -> {
if (responseOpen) terminal.println()
responseOpen = true
// Без префикса — текст будет стримиться дальше через AppendText.
}
is Event.AppendText -> {
terminal.print(event.body)
}
is Event.AppendImage -> {
terminal.print("[image:${event.mime}:${event.body.size} bytes]")
}
is Event.Interrupted -> {
if (responseOpen) {
terminal.println()
terminal.printSystem("[interrupted]")
responseOpen = false
} else {
terminal.printSystem("[interrupted]")
}
}
is Event.End -> {
if (responseOpen) {
terminal.println()
responseOpen = false
}
}
is Event.Error -> {
terminal.println()
terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}")
if (responseOpen) responseOpen = false
}
else -> {
// ToolCall/ToolResult — это «структура» диалога, в текстовом стриме
// не показываем; в веб-UI будет по-другому.
terminal.printSystem("[event:${event::class.simpleName}]")
}
}
}
fun close() {
responseOpen = false
}
}
@@ -1,105 +0,0 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.runBlocking
/**
* Точка входа CLI. Поддерживает аргументы командной строки:
*
* ```
* agentik-cli [--server URL] [--id ID] [--no-history] [--help]
*
* --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или
* http://localhost:8080/agentik)
* --id ID идентификатор этого клиента (default "cli:$USER")
* --no-history не сохранять состояние в ~/.agentik/cli-state.json
* --help, -h распечатать usage и выйти
* ```
*
* Без аргументов — стартует REPL.
*/
fun main(args: Array<String>) = runBlocking {
val cfg = parseCliArgs(args)
if (cfg == null) {
printUsage()
return@runBlocking
}
AgentikCli(cfg).run()
}
/**
* Конфигурация CLI, вычисленная из аргументов + переменных среды.
* Доступна из других файлов commonMain (видна как `internal` внутри модуля).
*/
internal data class CliConfig(
val server: String,
val id: String,
val historyEnabled: Boolean,
)
private fun parseCliArgs(args: Array<String>): CliConfig? {
var server: String? = null
var id: String? = null
var historyEnabled = true
var i = 0
while (i < args.size) {
when (val a = args[i]) {
"--help", "-h", "help" -> return null
"--server", "-s" -> {
require(i + 1 < args.size) { "$a требует URL" }
server = args[i + 1]; i += 2
}
"--id" -> {
require(i + 1 < args.size) { "$a требует значение" }
id = args[i + 1]; i += 2
}
"--no-history" -> { historyEnabled = false; i++ }
"--" -> i++ // разделитель; остальное игнорируем
else -> error("неизвестный аргумент: $a (введите --help)")
}
}
val resolvedServer = server
?: CliPlatform.env("AGENTIK_SERVER")
?: "http://localhost:8080/agentik"
val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}"
return CliConfig(
server = resolvedServer,
id = resolvedId,
historyEnabled = historyEnabled,
)
}
private fun printUsage() {
val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"
println("""
agentik-cli — REPL поверх протокола agentik
Использование:
agentik-cli [--server URL] [--id ID] [--no-history]
Аргументы:
--server, -s URL базовый URL (default: $defaultServer)
--id ID идентификатор клиента (default: cli:${defaultUser})
--no-history не сохранять состояние в ~/.agentik/cli-state.json
--help, -h эта справка
Переменные среды:
AGENTIK_SERVER базовый URL агента (используется если --server не задан)
HOME для пути ~/.agentik/cli-state.json
В REPL:
/help список slash-команд
/new [title] создать диалог (title опционально)
/list, /ls список диалогов
/switch <id>, /sw <id> переключиться на диалог
/rename <title> переименовать текущий диалог
/delete [<id>], /rm удалить (по id или текущий)
/history, /h последние сообщения текущего диалога
/interrupt, /stop прервать текущий ход
/pwd показать текущий диалог
/exit, /quit выйти (Ctrl-D тоже работает)
""".trimIndent())
}
@@ -0,0 +1,3 @@
package pw.binom.agentik.cli
internal fun platformEnv(key: String): String? = System.getenv(key)
@@ -1,81 +0,0 @@
package pw.binom.agentik.cli
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Instant
/**
* Состояние CLI между запусками: последний выбранный диалог и момент последнего
* увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта).
*
* Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего
* не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась
* под все цели. Файл: `$HOME/.agentik/cli-state.json`.
*/
internal class SessionRepository internal constructor(
private val filePath: String?,
private val io: SessionIo,
) {
@Serializable
private data class State(
val conversationId: String,
val lastEventAt: String,
)
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
/** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */
private var cached: State? = null
fun load(): SavedSession? {
val path = filePath ?: return null
val raw = io.readAll(path) ?: return null
return runCatching {
val state = json.decodeFromString(State.serializer(), raw)
cached = state
SavedSession(
conversationId = state.conversationId,
lastEventAt = Instant.parse(state.lastEventAt),
)
}.getOrNull()
}
fun save(conversationId: String, lastEventAt: Instant) {
val path = filePath ?: return
val state = State(
conversationId = conversationId,
lastEventAt = lastEventAt.toString(),
)
cached = state
val body = json.encodeToString(State.serializer(), state)
io.writeAtomic(path, body)
}
fun clear() {
val path = filePath ?: return
io.delete(path)
cached = null
}
fun close() {
// для совместимости с будущим in-memory state; пока no-op
}
}
internal data class SavedSession(
val conversationId: String,
val lastEventAt: Instant,
)
/**
* Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации
* в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub).
*
* public, потому что его возвращает public [CliPlatform.sessionIo].
*/
interface SessionIo {
fun readAll(path: String): String?
fun writeAtomic(path: String, body: String)
fun delete(path: String)
}
@@ -1,90 +0,0 @@
package pw.binom.agentik.cli
/**
* Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал.
*
* Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в
* текущий диалог и НЕ разбирается в [parse].
*/
sealed interface SlashCommand {
data object Help : SlashCommand
data object Exit : SlashCommand
data object Quit : SlashCommand // синоним Exit
/** Создать новый диалог; опционально — заголовок. */
data class New(val title: String?) : SlashCommand
/** Список диалогов (cold flow — печатаем по мере прихода страниц). */
data object List : SlashCommand
/** Подключиться к существующему диалогу по id. */
data class Switch(val id: String) : SlashCommand
/** Переименовать текущий диалог. */
data class Rename(val title: String) : SlashCommand
/** Удалить диалог (по id или текущий). */
data class Delete(val id: String?) : SlashCommand
/** Прервать текущий ход. no-op если хода нет. */
data object Interrupt : SlashCommand
/** Показать последние сообщения текущего диалога (cold flow). */
data object History : SlashCommand
/** Показать информацию о текущем диалоге. */
data object Pwd : SlashCommand
}
/**
* Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure]
* с сообщением об ошибке.
*
* Команды нечувствительны к регистру (команда `/LIST` == `/list`).
*/
fun parseSlash(input: String): ParseResult {
val s = input.trim()
if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)")
// Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar"
val firstSpace = s.indexOfAny(charArrayOf(' ', '\t'))
val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace)
val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim()
val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() }
val command: SlashCommand? = when (cmd.lowercase()) {
"help", "?" -> SlashCommand.Help
"exit" -> SlashCommand.Exit
"quit", "q" -> SlashCommand.Quit
"new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() })
"list", "ls" -> SlashCommand.List
"switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) }
"rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) }
"delete", "rm" -> SlashCommand.Delete(args.firstOrNull())
"interrupt", "stop", "cancel" -> SlashCommand.Interrupt
"history", "hist", "h" -> SlashCommand.History
"pwd", "where" -> SlashCommand.Pwd
else -> null
}
if (command != null) return ParseResult.Success(command)
// Не нашли команду: либо неизвестная, либо не хватает аргумента.
val cmdLower = cmd.lowercase()
return when (cmdLower) {
"switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>")
"rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>")
else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)")
}
}
sealed interface ParseResult {
data class Success(val command: SlashCommand) : ParseResult
data class Failure(val message: String) : ParseResult
}
/** Удобный helper для тестов и общего кода. */
fun parseSlashOrNull(input: String): SlashCommand? =
when (val r = parseSlash(input)) {
is ParseResult.Success -> r.command
is ParseResult.Failure -> null
}
@@ -0,0 +1,15 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvDeleteSubcommand : AgentikSubcommand("conv-delete", "Удалить диалог") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val ok = agent.deleteConversation(id)
if (ok) println("deleted: $id") else println("conversation not found: $id")
}
}
@@ -0,0 +1,32 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
class ConvLsSubcommand : AgentikSubcommand("conv-ls", "Список диалогов агента") {
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум диалогов").default(Agent.PAGE_SIZE)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val convs = agent.getConversations(offset = 0, limit = limit.coerceAtMost(Agent.PAGE_SIZE))
if (convs.isEmpty()) {
println("(no conversations)")
return@runBlocking
}
println("ID UPDATED-AT TITLE FLAGS")
convs.forEach { c ->
val flags = buildString {
if (c.isTemporal) append('T')
if (c.isSupportImageInput) append('I')
if (c.isSupportImageOutput) append('O')
if (isEmpty()) append('-')
}
val title = c.title ?: "(untitled)"
println("${c.id.padEnd(38)} ${c.updatedAt.toString().padEnd(22)} ${title.take(30).padEnd(31)} $flags")
}
println("--- ${convs.size} conversation(s)")
}
}
@@ -0,0 +1,16 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvNewSubcommand : AgentikSubcommand("conv-new", "Создать диалог; печатает id") {
val temp by option(ArgType.Boolean, fullName = "temp", description = "Временный диалог").default(false)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.createConversation(temp = temp)
println(conv.id)
}
}
@@ -0,0 +1,24 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvRenameSubcommand : AgentikSubcommand("conv-rename", "Переименовать диалог") {
val id by argument(ArgType.String, description = "ID диалога")
val title by argument(ArgType.String, description = "Новое название")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
conv.rename(title)
} finally {
conv.close()
}
println("renamed: $id -> $title")
}
}
@@ -0,0 +1,27 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class ConvShowSubcommand : AgentikSubcommand("conv-show", "Метаданные диалога") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
println("id: ${conv.id}")
println("title: ${conv.title ?: "(untitled)"}")
println("updatedAt: ${conv.updatedAt}")
println("isTemporal: ${conv.isTemporal}")
println("isSupportImageInput: ${conv.isSupportImageInput}")
println("isSupportImageOutput: ${conv.isSupportImageOutput}")
} finally {
conv.close()
}
}
}
@@ -0,0 +1,10 @@
package pw.binom.agentik.cli.commands
import pw.binom.agentik.cli.AgentikSubcommand
class InfoSubcommand : AgentikSubcommand("info", "Показать server URL и agent id") {
override fun execute() {
println("server: $serverUrl")
println("id: $agentId")
}
}
@@ -0,0 +1,23 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
class InterruptSubcommand : AgentikSubcommand("interrupt", "Прервать текущий ход диалога") {
val id by argument(ArgType.String, description = "ID диалога")
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
conv.interrupt()
println("interrupted: $id")
} finally {
conv.close()
}
}
}
@@ -0,0 +1,54 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.default
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
class MsgsSubcommand : AgentikSubcommand("msgs", "Показать сообщения диалога") {
val id by argument(ArgType.String, description = "ID диалога")
val limit by option(ArgType.Int, fullName = "limit", description = "Максимум сообщений").default(100)
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
val msgs = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = limit)
.sortedBy { it.date }
msgs.forEach { m -> println(formatMessage(m)) }
println("--- ${msgs.size} message(s)")
} finally {
conv.close()
}
}
private fun formatMessage(m: Message): String =
"[${m.date}] ${m.role().padEnd(11)} ${m.bodyOneLine()}"
private fun Message.role(): String = when (this) {
is Message.UserMessage -> "[user]"
is Message.AssistantMessage -> "[assistant]"
is Message.ToolCall -> "[tool_call]"
is Message.ToolResult -> "[tool_result]"
is Message.Error -> "[error]"
}
private fun Message.bodyOneLine(): String = when (this) {
is Message.UserMessage -> content.joinToString(" ") { c -> c.toOneLine() }
is Message.AssistantMessage -> content.joinToString(" ") { c -> c.toOneLine() }
is Message.ToolCall -> "tool=$toolName args=$toolArgs"
is Message.ToolResult -> "id=$id result=${result ?: "<null>"}"
is Message.Error -> "code=${code ?: "?"} message=$message"
}
private fun Content.toOneLine(): String = when (this) {
is Content.Text -> body.replace('\n', ' ').take(200)
is Content.Image -> "<image ${data.size}B $mime>"
}
}
@@ -0,0 +1,63 @@
package pw.binom.agentik.cli.commands
import kotlinx.cli.ArgType
import kotlinx.cli.vararg
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import pw.binom.agentik.cli.AgentikSubcommand
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
class SendSubcommand : AgentikSubcommand("send", "Отправить user-ход и стримить ответ") {
val id by argument(ArgType.String, description = "ID диалога")
val text by argument(ArgType.String, description = "Текст хода (все позиционные после <id> склеиваются пробелом)").vararg()
override fun execute() = kotlinx.coroutines.runBlocking {
val agent = AgentikAgent(id = agentId, baseUrl = serverUrl)
val conv = agent.getConversation(id) ?: run {
println("conversation not found: $id")
return@runBlocking
}
try {
// Подписываемся на поток событий ДО send: события, отправленные
// до подписки, не реплеятся (shared-flow без replay).
val eventsJob = launch {
conv.events(Instant.DISTANT_PAST)
// onEach печатает и терминальный event, takeWhile лишь
// завершает сбор после него.
.onEach { ev -> emit(ev) }
.takeWhile { ev -> !isTerminal(ev) }
.collect { }
}
// Даём SSE-подписке установиться, затем шлём ход.
delay(200)
conv.send(listOf(Content.Text(text.joinToString(" "))))
eventsJob.join()
} finally {
conv.close()
}
}
private fun isTerminal(ev: Event): Boolean =
ev is Event.End || ev is Event.Interrupted || ev is Event.Error
private fun emit(ev: Event) {
when (ev) {
is Event.StartReasoning -> println("event StartReasoning")
is Event.StartResponse -> println("event StartResponse ${ev.responseType}")
is Event.AppendText -> println("event AppendText ${escape(ev.body)}")
is Event.AppendImage -> println("event AppendImage <${ev.body.size}B ${ev.mime}>")
is Event.ToolCall -> println("event ToolCall ${ev.id} ${ev.toolName} ${escape(ev.toolArgs)}")
is Event.ToolResult -> println("event ToolResult ${ev.id} ${escape(ev.result ?: "")}")
is Event.End -> println("event End")
is Event.Interrupted -> println("event Interrupted")
is Event.Error -> println("event Error ${ev.code ?: ""} ${escape(ev.message)}")
}
}
private fun escape(s: String): String = s.replace("\n", "\\n").replace("\r", "\\r")
}
@@ -1,81 +0,0 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.proto.Event
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Подменяем [CliTerminal] простой in-memory реализацией и проверяем,
* что события рендерятся в правильном формате.
*/
class EventRendererTest {
private class FakeTerminal : CliTerminal {
override val prompt: String = ">"
val out = StringBuilder()
override suspend fun readLine(): String? = null
override suspend fun println(text: String) { out.appendLine(text) }
override suspend fun print(text: String) { out.append(text) }
override suspend fun printSystem(text: String) { out.appendLine("· $text") }
override fun close() {}
fun text() = out.toString()
}
@Test
fun `simple response stream`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет"))
r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!"))
r.render(Event.End(Instant.DISTANT_PAST))
// StartResponse открывает блок, AppendText без \n, End закрывает \n
val text = t.text()
assertTrue(text.contains("Привет, мир!"), "got: $text")
// после End должен быть перевод строки
assertTrue(text.endsWith("\n"))
}
@Test
fun `interrupted closes block`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT))
r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично"))
r.render(Event.Interrupted(Instant.DISTANT_PAST))
val text = t.text()
assertTrue(text.contains("Частично"))
assertTrue(text.contains("· [interrupted]"))
}
@Test
fun `error before response`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500"))
val text = t.text()
assertTrue(text.contains("· [error/500] что-то сломалось"))
}
@Test
fun `start_reasoning is printed as system line`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartReasoning(Instant.DISTANT_PAST))
assertTrue(t.text().contains("· …thinking…"))
}
@Test
fun `image append renders placeholder`() = runTest {
val t = FakeTerminal()
val r = EventRenderer(t)
r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE))
r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png"))
r.render(Event.End(Instant.DISTANT_PAST))
assertTrue(t.text().contains("[image:image/png:64 bytes]"))
}
}
@@ -1,101 +0,0 @@
package pw.binom.agentik.cli
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class SlashCommandTest {
@Test
fun `help is parsed`() {
assertIs<SlashCommand.Help>(parseSlashOrNull("help"))
assertIs<SlashCommand.Help>(parseSlashOrNull("?"))
assertIs<SlashCommand.Help>(parseSlashOrNull("HELP"))
}
@Test
fun `exit and quit alias`() {
assertIs<SlashCommand.Exit>(parseSlashOrNull("exit"))
assertIs<SlashCommand.Quit>(parseSlashOrNull("q"))
assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit"))
}
@Test
fun `new without title`() {
assertIs<SlashCommand.New>(parseSlashOrNull("new")).let {
assertEquals(null, it.title)
}
}
@Test
fun `new with multi-word title`() {
val cmd = parseSlashOrNull("new my cool chat")
assertIs<SlashCommand.New>(cmd)
assertEquals("my cool chat", cmd.title)
}
@Test
fun `switch requires id`() {
val r = parseSlash("sw")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `switch with id`() {
val cmd = parseSlashOrNull("switch abc123")
assertIs<SlashCommand.Switch>(cmd)
assertEquals("abc123", cmd.id)
}
@Test
fun `rename requires title`() {
val r = parseSlash("rename")
assertIs<ParseResult.Failure>(r)
// А "rename " (с пробелом, но без слов после) — это уже успех с пустым title?
// У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК.
}
@Test
fun `rename with title`() {
val cmd = parseSlashOrNull("rename my new title ")
assertIs<SlashCommand.Rename>(cmd)
assertEquals("my new title", cmd.title) // trim() делает своё
}
@Test
fun `delete may have id or not`() {
assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let {
assertEquals(null, it.id)
}
assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let {
assertEquals("abc", it.id)
}
}
@Test
fun `unknown command fails`() {
val r = parseSlash("foobar")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `empty command fails`() {
val r = parseSlash("")
assertIs<ParseResult.Failure>(r)
}
@Test
fun `command is case insensitive`() {
assertIs<SlashCommand.List>(parseSlashOrNull("LIST"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP"))
assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD"))
}
@Test
fun `interrupt synonyms`() {
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop"))
assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel"))
}
}
@@ -1,151 +0,0 @@
package pw.binom.agentik.cli
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jline.reader.EndOfFileException
import org.jline.reader.LineReader
import org.jline.reader.LineReaderBuilder
import org.jline.reader.UserInterruptException
import org.jline.terminal.TerminalBuilder
import pw.binom.agentik.client.AgentikAgent
import pw.binom.agentik.proto.Agent
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
actual object CliPlatform {
actual fun openAgent(baseUrl: String, id: String): Agent =
AgentikAgent(id = id, baseUrl = baseUrl)
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
JLineTerminal(historyFile = historyFile, prompt = prompt)
actual fun homeDir(): String? =
System.getenv("HOME") ?: System.getenv("USERPROFILE")
actual fun env(key: String): String? = System.getenv(key)
actual fun sessionIo(): SessionIo = JvmSessionIo
}
/**
* Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`.
* tmp-файл пишется в той же директории, что и целевой, чтобы rename
* был атомарным в рамках одного раздела (POSIX rename(2) и Windows
* MoveFileEx — атомарны внутри одного тома).
*/
private object JvmSessionIo : SessionIo {
override fun readAll(path: String): String? {
val f = File(path)
if (!f.exists()) return null
return runCatching { f.readText() }.getOrNull()
}
override fun writeAtomic(path: String, body: String) {
val target = File(path)
target.parentFile?.mkdirs()
val tmp = File(path + ".tmp")
tmp.writeText(body)
if (!tmp.renameTo(target)) {
// fallback: Windows-специфика — renameTo может не перезаписать существующий.
runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) }
.getOrElse { target.writeText(tmp.readText()); tmp.delete() }
}
} override fun delete(path: String) {
runCatching { File(path).delete() }
}
}
/**
* Реализация [CliTerminal] поверх JLine ([LineReader]).
*
* JLine-3 API:
* - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY.
* - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E).
* - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем
* в [withContext] [Dispatchers.IO], чтобы не держать event-loop.
* - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла.
*/
private class JLineTerminal(
historyFile: String?,
override val prompt: String,
) : CliTerminal {
private val terminal = TerminalBuilder.builder()
.system(true)
.jna(true)
.build()
private val historyImpl: org.jline.reader.History? = run {
if (historyFile == null) null else try {
val history = org.jline.reader.impl.history.DefaultHistory()
val histFile = File(historyFile)
histFile.parentFile?.mkdirs()
history.load()
if (histFile.exists()) {
history.append(histFile.toPath(), true)
}
history
} catch (t: Throwable) {
null
}
}
private val reader: LineReader = LineReaderBuilder.builder()
.terminal(terminal)
.apply { if (historyImpl != null) history(historyImpl) }
.build()
private val historyFilePath: java.nio.file.Path? =
historyFile?.let { File(it).toPath() }
override suspend fun readLine(): String? = withContext(Dispatchers.IO) {
try {
val line = reader.readLine(prompt)
// Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C
// ничего не теряется.
flushHistory()
line
} catch (_: UserInterruptException) {
// Ctrl-C: трактуем как «всё, выходим», как и EOF.
flushHistory()
null
} catch (_: EndOfFileException) {
// Ctrl-D на пустой строке.
flushHistory()
null
}
}
override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().println(text)
terminal.writer().flush()
}
override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().print(text)
terminal.writer().flush()
}
override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) {
terminal.writer().println("· $text")
terminal.writer().flush()
}
private fun flushHistory() {
val hf = historyFilePath ?: return
val h = historyImpl ?: return
runCatching {
h.save()
if (!h.isEmpty) {
// читаем из .tmp и дописываем
h.append(hf, true)
}
}
}
override fun close() {
runCatching { flushHistory() }
runCatching { terminal.close() }
}
}
@@ -1,108 +0,0 @@
package pw.binom.agentik.cli
import org.junit.After
import org.junit.Before
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Интеграционный тест на реальном временном файле. Только JVM: использует
* [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается —
* TODO: переписать на kotlinx-io Files и перенести в commonTest.
*/
class SessionRepositoryTest {
private lateinit var tmp: File
@Before
fun setUp() {
tmp = File.createTempFile("agentik-cli-state", ".json")
tmp.delete()
}
@After
fun tearDown() {
if (tmp.exists()) tmp.delete()
File(tmp.path + ".tmp").delete()
}
@Test
fun `load returns null when file missing`() {
val repo = SessionRepository(tmp.path, JvmIo)
assertNull(repo.load())
}
@Test
fun `save then load roundtrip`() {
val repo = SessionRepository(tmp.path, JvmIo)
val savedAt = Instant.parse("2026-09-16T10:00:00Z")
repo.save(conversationId = "abcd-1234", lastEventAt = savedAt)
repo.close()
val repo2 = SessionRepository(tmp.path, JvmIo)
val restored = repo2.load()
assertNotNull(restored)
assertEquals("abcd-1234", restored.conversationId)
assertEquals(savedAt, restored.lastEventAt)
}
@Test
fun `save overwrites previous state`() {
val repo = SessionRepository(tmp.path, JvmIo)
repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z"))
repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z"))
repo.close()
val restored = SessionRepository(tmp.path, JvmIo).load()
assertNotNull(restored)
assertEquals("conv-2", restored.conversationId)
}
@Test
fun `null filepath means no-op`() {
val repo = SessionRepository(null, JvmIo)
repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z"))
// Не должно ни читать, ни писать.
assertNull(repo.load())
}
@Test
fun `clear deletes file`() {
val repo = SessionRepository(tmp.path, JvmIo)
repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z"))
repo.close()
assertTrue(tmp.exists())
val repo2 = SessionRepository(tmp.path, JvmIo)
repo2.clear()
assertTrue(!tmp.exists())
}
@Test
fun `corrupt json is ignored (does not throw)`() {
File(tmp.path).writeText("this is not json")
val repo = SessionRepository(tmp.path, JvmIo)
assertNull(repo.load())
}
}
// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста.
// В продакшен-коде на jvmMain ровно такая же логика.
private object JvmIo : SessionIo {
override fun readAll(path: String): String? {
val f = File(path); if (!f.exists()) return null
return runCatching { f.readText() }.getOrNull()
}
override fun writeAtomic(path: String, body: String) {
val target = File(path); target.parentFile?.mkdirs()
val tmp = File(path + ".tmp")
tmp.writeText(body)
if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() }
}
override fun delete(path: String) { File(path).delete() }
}
@@ -1,36 +0,0 @@
package pw.binom.agentik.cli
import pw.binom.agentik.proto.Agent
/**
* Платформо-зависимая реализация для native-целей.
*
* Текущий статус: stub. native HTTP требует подключения ktor-client-core +
* платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для
* linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования
* уже существующего `:client` SSE-парсера. Native readline требует termios
* через `kotlinx.cinterop` — добавим, когда дойдут руки.
*
* Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется
* под все 8 KMP-целей — структурная готовность соблюдена.
*/
actual object CliPlatform {
actual fun openAgent(baseUrl: String, id: String): Agent =
error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)")
actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal =
error("agentik-cli native target is not implemented yet (prompt=$prompt)")
actual fun homeDir(): String? = null
actual fun env(key: String): String? = null
actual fun sessionIo(): SessionIo = NoopSessionIo
}
/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */
private object NoopSessionIo : SessionIo {
override fun readAll(path: String): String? = null
override fun writeAtomic(path: String, body: String) {}
override fun delete(path: String) {}
}