Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ tests/skill_engine/*
!tests/skill_engine/test_evolver_length_recovery.py
!tests/skill_engine/test_evolution_retry_idempotency.py
!tests/skill_engine/test_analyzer_length_recovery.py
!tests/skill_engine/test_set_frontmatter_multiline.py
!tests/skill_engine/decision/
tests/skill_engine/decision/*
!tests/skill_engine/decision/test_analysis_adapter.py
Expand Down
26 changes: 22 additions & 4 deletions openspace/skill_engine/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,16 @@ def is_skill_safe(flags: List[str]) -> bool:

def _yaml_quote(value: str) -> str:
"""Quote a YAML scalar value if it contains special characters."""
if not value or not _YAML_NEEDS_QUOTE_RE.search(value):
if not value:
return value
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
if "\n" in value or _YAML_NEEDS_QUOTE_RE.search(value):
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
)
return f'"{escaped}"'
return value


def _yaml_unquote(value: str) -> str:
Expand All @@ -71,7 +77,12 @@ def _yaml_unquote(value: str) -> str:
(value[0] == "'" and value[-1] == "'"):
inner = value[1:-1]
if value[0] == '"':
inner = inner.replace('\\"', '"').replace("\\\\", "\\")
inner = (
inner.replace("\\\\", "\0")
.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\0", "\\")
)
return inner
return value

Expand Down Expand Up @@ -147,10 +158,17 @@ def set_frontmatter_field(content: str, field_name: str, value: str) -> str:
new_line = f"{field_name}: {quoted}"
found = False
new_lines = []
skip_block = False
for line in fm_text.split("\n"):
if skip_block:
if line.startswith((" ", "\t")) or line.strip() == "":
continue
skip_block = False
if ":" in line and line.split(":", 1)[0].strip() == field_name:
new_lines.append(new_line)
found = True
# Drop old block-scalar / folded continuation lines under this key.
skip_block = True
else:
new_lines.append(line)
if not found:
Expand Down
38 changes: 38 additions & 0 deletions tests/skill_engine/test_set_frontmatter_multiline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Regression: multiline frontmatter values must round-trip via parse_frontmatter."""

from __future__ import annotations

import builtins

from openspace.skill_engine.skill_utils import (
get_frontmatter_field,
parse_frontmatter,
set_frontmatter_field,
)


def test_set_frontmatter_field_preserves_multiline_description():
out = set_frontmatter_field("---\nname: x\n---\n", "description", "line1\nline2")
assert parse_frontmatter(out)["description"] == "line1\nline2"


def test_set_frontmatter_field_replaces_block_scalar_without_orphan_lines():
src = "---\nname: x\ndescription: |\n old1\n old2\n---\nbody\n"
out = set_frontmatter_field(src, "description", "new1\nnew2")
assert parse_frontmatter(out)["description"] == "new1\nnew2"
assert "old1" not in out
assert "old2" not in out


def test_multiline_round_trip_without_pyyaml(monkeypatch):
real_import = builtins.__import__

def no_yaml(name, *args, **kwargs):
if name == "yaml" or name.startswith("yaml."):
raise ImportError("forced missing yaml")
return real_import(name, *args, **kwargs)

monkeypatch.setattr(builtins, "__import__", no_yaml)
out = set_frontmatter_field("---\nname: x\n---\n", "description", "line1\nline2")
assert get_frontmatter_field(out, "description") == "line1\nline2"
assert parse_frontmatter(out)["description"] == "line1\nline2"