fix(skills): SkillParser.serialize — закрывающий fence прилипал к последней YAML-строке

kaml encodeToString не ставит завершающий перевод строки, из-за чего
сериализованный SKILL.md выглядел так:
  ---
  name: x
  description: "y"---
и SkillParser.parse находил MissingClosingFence — каждый скил,
сохранённый через skill_save / SkillMiner, становился нечитаемым после
рестарта агента (каталог терял скил).

Поставлен явный '\n' перед закрывающим fence. Добавлены юнит-тесты
round-trip serialize->parse (обычный, пустой body, спецсимволы YAML).
This commit is contained in:
2026-09-15 07:01:23 +03:00
parent df386ef875
commit 9b37edd92e
6 changed files with 140 additions and 67 deletions
@@ -152,7 +152,11 @@ object SkillParser {
return buildString {
append("---\n")
append(yamlBody)
append("---\n")
// kaml encodeToString не добавляет завершающий перевод строки — без
// явного `\n` закрывающий fence прилипает к последней YAML-строке
// (`description: "..."---`) и [parse] больше не находит fence
// (MissingClosingFence).
append("\n---\n")
if (body.isNotEmpty()) {
append("\n")
append(body)
@@ -181,7 +181,52 @@ class SkillParserTest {
assertTrue(ok.value.description.contains("line two"))
}
// --- parseAuto: fenced делегируется в parse, голый YAML трактуется как объект ---
// --- serialize → parse round-trip (регрессия: кривой fence после serialize) ---
@Test
fun serializeParseRoundTrip() {
val skill = SkillFile(
name = "greet:ru",
description = "Приветствие по-русски",
body = "# Greet RU\nКогда пользователь здоровается, отвечай тепло и кратко по-русски.",
)
val serialized = SkillParser.serialize(skill)
// Закрывающий fence обязан быть на отдельной строке — иначе parse
// не найдёт его (MissingClosingFence).
assertTrue(
"\n---" in serialized,
"закрывающий fence должен начинаться с новой строки, got: $serialized",
)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals("greet:ru", ok.value.name)
assertEquals("Приветствие по-русски", ok.value.description)
assertTrue(ok.value.body.startsWith("# Greet RU"))
}
@Test
fun serializeParseRoundTripEmptyBody() {
val skill = SkillFile(name = "empty-body", description = "no body", body = "")
val serialized = SkillParser.serialize(skill)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals("empty-body", ok.value.name)
assertEquals("", ok.value.body)
}
@Test
fun serializeParseRoundTripSpecialYamlChars() {
val skill = SkillFile(
name = "special",
description = "С кавычками \"и\" колонкой: и двоеточие.",
body = "line1\nline2",
)
val serialized = SkillParser.serialize(skill)
val ok = assertIs<SkillParseResult.Ok>(SkillParser.parse(serialized))
assertEquals(skill.description, ok.value.description)
// parse.trimStart('\n') не режет хвостовой newline — сравниваем с trimEnd.
assertEquals("line1\nline2", ok.value.body.trimEnd('\n'))
}
// --- parseAuto ---
@Test
fun parseAutoDelegatesFencedFormat() {