runtime
This commit is contained in:
425
skills/compact-memory/tests/test_compact_memory_auto.py
Normal file
425
skills/compact-memory/tests/test_compact_memory_auto.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""Tests for compact_memory_auto.py — change-set parsing, validation, applying and reporting.
|
||||
|
||||
The nanobot import is deferred inside _run, so importing the module needs no nanobot-ai install.
|
||||
Nothing here touches the network or an LLM; every tested function is pure or writes into tmp_path.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import compact_memory_auto as cma
|
||||
|
||||
MEMORY_TEXT = """# MEMORY
|
||||
|
||||
## Infra
|
||||
|
||||
- Runs as a systemd user service `nanobot.service`
|
||||
- Model switching via `my` tool needs `tools.my.allow_set = true`
|
||||
- Telegram bot polls every 2 seconds
|
||||
|
||||
## Projects
|
||||
|
||||
### compact-memory
|
||||
|
||||
- Nightly job driven by the crontab
|
||||
- Debug run 2026-07-25: output-format test in progress
|
||||
- Change-set validated by the script
|
||||
|
||||
### remind
|
||||
|
||||
- Reminders live in a SQLite database
|
||||
- Cron sends due reminders every minute
|
||||
"""
|
||||
|
||||
DEBUG_LINE = "- Debug run 2026-07-25: output-format test in progress"
|
||||
SYSTEMD_LINE = "- Runs as a systemd user service `nanobot.service`"
|
||||
PRESET_LINE = "- Model switching via `my` tool needs `tools.my.allow_set = true`"
|
||||
|
||||
NOW = datetime(2026, 7, 27, 2, 5)
|
||||
|
||||
|
||||
def delete_item(original, reason="ephemeral marker", category="ephemeral", **overrides):
|
||||
item = {"op": "delete", "category": category, "original": original, "reason": reason}
|
||||
item.update(overrides)
|
||||
return item
|
||||
|
||||
|
||||
def merge_item(original, new_text, reason="same subsection, one topic", **overrides):
|
||||
item = {"op": "merge", "original": original, "new_text": new_text, "reason": reason}
|
||||
item.update(overrides)
|
||||
return item
|
||||
|
||||
|
||||
def answer(*changes):
|
||||
"""Wrap changes the way the agent does — one fenced json block."""
|
||||
return "```json\n" + json.dumps({"changes": list(changes)}) + "\n```"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
memory = tmp_path / "memory"
|
||||
memory.mkdir()
|
||||
(memory / "MEMORY.md").write_text(MEMORY_TEXT, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
# --- _extract_json ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_json_from_fenced_block():
|
||||
assert cma._extract_json('```json\n{"changes": []}\n```') == {"changes": []}
|
||||
|
||||
|
||||
def test_extract_json_from_bare_answer():
|
||||
assert cma._extract_json('{"changes": []}') == {"changes": []}
|
||||
|
||||
|
||||
def test_extract_json_takes_last_parseable_block():
|
||||
content = '```json\n{"changes": [1]}\n```\ntext\n```json\n{"changes": [2]}\n```'
|
||||
assert cma._extract_json(content) == {"changes": [2]}
|
||||
|
||||
|
||||
def test_extract_json_without_json_raises():
|
||||
with pytest.raises(cma.ChangeSetError, match="no parseable"):
|
||||
cma._extract_json("I audited the file and found nothing.")
|
||||
|
||||
|
||||
# --- parse_change_set: happy paths -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_delete():
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
|
||||
assert len(located) == 1
|
||||
change = located[0].change
|
||||
assert change.op == "delete"
|
||||
assert change.category == "ephemeral"
|
||||
assert change.original == (DEBUG_LINE,)
|
||||
assert change.new_text == ()
|
||||
|
||||
|
||||
def test_parse_merge():
|
||||
merged = "- systemd service `nanobot.service`; model switching needs `tools.my.allow_set = true`"
|
||||
located = cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])), MEMORY_TEXT)
|
||||
assert len(located) == 1
|
||||
assert located[0].change.op == "merge"
|
||||
assert located[0].change.category == "merge"
|
||||
assert located[0].change.new_text == (merged,)
|
||||
|
||||
|
||||
def test_parse_empty_change_set():
|
||||
assert cma.parse_change_set(answer(), MEMORY_TEXT) == []
|
||||
|
||||
|
||||
def test_parse_strips_reason_whitespace():
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=" stale ")), MEMORY_TEXT)
|
||||
assert located[0].change.reason == "stale"
|
||||
|
||||
|
||||
def test_parse_reports_span_of_matched_block():
|
||||
lines = MEMORY_TEXT.splitlines()
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
|
||||
assert located[0].start == lines.index(DEBUG_LINE)
|
||||
assert located[0].end == located[0].start + 1
|
||||
|
||||
|
||||
# --- parse_change_set: payload shape ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_payload_must_be_object():
|
||||
with pytest.raises(cma.ChangeSetError, match="must be an object"):
|
||||
cma.parse_change_set("```json\n[]\n```", MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_payload_needs_changes_list():
|
||||
with pytest.raises(cma.ChangeSetError, match='"changes" list'):
|
||||
cma.parse_change_set('```json\n{"items": []}\n```', MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_item_must_be_object():
|
||||
with pytest.raises(cma.ChangeSetError, match="must be a JSON object"):
|
||||
cma.parse_change_set(answer("delete everything"), MEMORY_TEXT)
|
||||
|
||||
|
||||
# --- parse_change_set: field validation ------------------------------------------------------
|
||||
|
||||
|
||||
def test_unknown_op_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match='"op" must be'):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], op="rewrite")), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_unknown_field_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match="unknown fields"):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], note="extra")), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_merge_field_on_delete_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match="unknown fields"):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], new_text=["x"])), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_empty_reason_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match='"reason" must be a non-empty string'):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=" ")), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_too_long_reason_rejected():
|
||||
long_reason = "x" * (cma.MAX_REASON_CHARS + 1)
|
||||
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_REASON_CHARS}"):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason=long_reason)), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_original_must_be_non_empty_list():
|
||||
with pytest.raises(cma.ChangeSetError, match='"original" must be a non-empty list'):
|
||||
cma.parse_change_set(answer(delete_item([])), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_original_must_be_list_of_strings():
|
||||
with pytest.raises(cma.ChangeSetError, match='"original" must be a non-empty list'):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE, 42])), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_too_many_original_lines_rejected():
|
||||
block = [f"- line {i}" for i in range(cma.MAX_ORIGINAL_LINES + 1)]
|
||||
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_ORIGINAL_LINES}"):
|
||||
cma.parse_change_set(answer(delete_item(block)), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_unknown_delete_category_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match='"category" must be one of'):
|
||||
cma.parse_change_set(answer(delete_item([DEBUG_LINE], category="obsolete")), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_too_many_new_text_lines_rejected():
|
||||
original = [SYSTEMD_LINE, PRESET_LINE, "- Telegram bot polls every 2 seconds"]
|
||||
new_text = [f"- merged {i}" for i in range(cma.MAX_NEW_TEXT_LINES + 1)]
|
||||
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_NEW_TEXT_LINES}"):
|
||||
cma.parse_change_set(answer(merge_item(original, new_text)), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_too_long_new_text_rejected():
|
||||
new_text = ["- " + "x" * cma.MAX_NEW_TEXT_CHARS]
|
||||
with pytest.raises(cma.ChangeSetError, match=f"the limit is {cma.MAX_NEW_TEXT_CHARS}"):
|
||||
cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], new_text)), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_merge_must_shrink():
|
||||
new_text = ["- one", "- two"]
|
||||
with pytest.raises(cma.ChangeSetError, match="fewer lines"):
|
||||
cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], new_text)), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_all_item_errors_reported_at_once():
|
||||
bad_op = delete_item([DEBUG_LINE], op="rewrite")
|
||||
bad_reason = delete_item([SYSTEMD_LINE], reason="")
|
||||
with pytest.raises(cma.ChangeSetError) as excinfo:
|
||||
cma.parse_change_set(answer(bad_op, bad_reason), MEMORY_TEXT)
|
||||
message = str(excinfo.value)
|
||||
assert "item 1:" in message
|
||||
assert "item 2:" in message
|
||||
|
||||
|
||||
# --- locating blocks in the file -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_block_rejected():
|
||||
with pytest.raises(cma.ChangeSetError, match=r"does not appear in MEMORY\.md"):
|
||||
cma.parse_change_set(answer(delete_item(["- this line was never in the file"])), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_ambiguous_block_rejected():
|
||||
text = "- duplicated bullet\n- something else\n- duplicated bullet\n"
|
||||
with pytest.raises(cma.ChangeSetError, match="appears 2 times"):
|
||||
cma.parse_change_set(answer(delete_item(["- duplicated bullet"])), text)
|
||||
|
||||
|
||||
def test_trailing_whitespace_is_ignored_when_matching():
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE + " "])), MEMORY_TEXT)
|
||||
assert located[0].change.original == (DEBUG_LINE + " ",)
|
||||
|
||||
|
||||
def test_located_changes_are_sorted_by_position():
|
||||
later = delete_item([DEBUG_LINE])
|
||||
earlier = delete_item([SYSTEMD_LINE], category="detail", reason="code reference")
|
||||
located = cma.parse_change_set(answer(later, earlier), MEMORY_TEXT)
|
||||
assert [item.change.original[0] for item in located] == [SYSTEMD_LINE, DEBUG_LINE]
|
||||
|
||||
|
||||
def test_overlapping_blocks_rejected():
|
||||
first = delete_item([SYSTEMD_LINE, PRESET_LINE])
|
||||
second = delete_item([PRESET_LINE, "- Telegram bot polls every 2 seconds"])
|
||||
with pytest.raises(cma.ChangeSetError, match="overlapping items"):
|
||||
cma.parse_change_set(answer(first, second), MEMORY_TEXT)
|
||||
|
||||
|
||||
def test_removing_more_than_half_the_file_rejected():
|
||||
text = "- one\n- two\n- three\n- four\n"
|
||||
block = ["- one", "- two", "- three"]
|
||||
with pytest.raises(cma.ChangeSetError, match="more than 50% of the file"):
|
||||
cma.parse_change_set(answer(delete_item(block)), text)
|
||||
|
||||
|
||||
# --- apply_change_set ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_delete_removes_the_block(workspace):
|
||||
memory = workspace / "memory" / "MEMORY.md"
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
|
||||
|
||||
lines_after = cma.apply_change_set(memory, located, workspace, NOW)
|
||||
|
||||
text = memory.read_text(encoding="utf-8")
|
||||
assert DEBUG_LINE not in text
|
||||
assert SYSTEMD_LINE in text
|
||||
assert text.endswith("\n")
|
||||
assert lines_after == len(MEMORY_TEXT.splitlines()) - 1
|
||||
assert lines_after == len(text.splitlines())
|
||||
|
||||
|
||||
def test_apply_merge_replaces_the_block(workspace):
|
||||
memory = workspace / "memory" / "MEMORY.md"
|
||||
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
|
||||
located = cma.parse_change_set(answer(merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])), MEMORY_TEXT)
|
||||
|
||||
lines_after = cma.apply_change_set(memory, located, workspace, NOW)
|
||||
|
||||
text = memory.read_text(encoding="utf-8")
|
||||
assert merged in text
|
||||
assert SYSTEMD_LINE not in text
|
||||
assert PRESET_LINE not in text
|
||||
assert lines_after == len(MEMORY_TEXT.splitlines()) - 1
|
||||
|
||||
|
||||
def test_apply_writes_backup_with_the_original_content(workspace):
|
||||
memory = workspace / "memory" / "MEMORY.md"
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
|
||||
|
||||
cma.apply_change_set(memory, located, workspace, NOW)
|
||||
|
||||
backup = workspace / "backup" / "2026-07-27_0205_memory.backup.md"
|
||||
assert backup.read_text(encoding="utf-8") == MEMORY_TEXT
|
||||
|
||||
|
||||
def test_apply_appends_to_the_clean_log(workspace):
|
||||
memory = workspace / "memory" / "MEMORY.md"
|
||||
clean_log = workspace / cma.CLEAN_LOG_REL
|
||||
clean_log.parent.mkdir(parents=True)
|
||||
clean_log.write_text('2026-07-26 02:05 DELETED [detail] "old entry" — earlier run\n', encoding="utf-8")
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE])), MEMORY_TEXT)
|
||||
|
||||
cma.apply_change_set(memory, located, workspace, NOW)
|
||||
|
||||
lines = clean_log.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 2
|
||||
assert lines[0].endswith("earlier run")
|
||||
assert lines[1].startswith("2026-07-27 02:05 DELETED [ephemeral]")
|
||||
|
||||
|
||||
def test_apply_logs_one_line_per_change(workspace):
|
||||
memory = workspace / "memory" / "MEMORY.md"
|
||||
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
|
||||
located = cma.parse_change_set(
|
||||
answer(delete_item([DEBUG_LINE]), merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])),
|
||||
MEMORY_TEXT,
|
||||
)
|
||||
|
||||
cma.apply_change_set(memory, located, workspace, NOW)
|
||||
|
||||
lines = (workspace / cma.CLEAN_LOG_REL).read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 2
|
||||
assert any("MERGED [merge]" in line for line in lines)
|
||||
assert any("DELETED [ephemeral]" in line for line in lines)
|
||||
|
||||
|
||||
# --- reporting -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_report_for_empty_change_set():
|
||||
assert cma.format_report([], 42, 42) == "Memory compact: nothing to remove (MEMORY.md, 42 lines)."
|
||||
|
||||
|
||||
def test_report_counts_deletes_and_merges():
|
||||
merged = "- systemd service; model switching needs `tools.my.allow_set = true`"
|
||||
located = cma.parse_change_set(
|
||||
answer(delete_item([DEBUG_LINE]), merge_item([SYSTEMD_LINE, PRESET_LINE], [merged])),
|
||||
MEMORY_TEXT,
|
||||
)
|
||||
|
||||
rows = cma.format_report(located, 20, 18).splitlines()
|
||||
|
||||
assert rows[0] == "Memory compact: deleted 1, merged 1 (20 → 18 lines)."
|
||||
assert len(rows) == 3
|
||||
assert any(row.startswith("- [merge] ") for row in rows[1:])
|
||||
assert any(row.startswith("- [ephemeral] ") for row in rows[1:])
|
||||
|
||||
|
||||
def test_report_quotes_reason_verbatim():
|
||||
located = cma.parse_change_set(answer(delete_item([DEBUG_LINE], reason="task finished")), MEMORY_TEXT)
|
||||
assert cma.format_report(located, 20, 19).endswith("— task finished")
|
||||
|
||||
|
||||
def test_log_line_for_delete():
|
||||
change = cma.Change(op="delete", category="detail", original=("- a path",), new_text=(), reason="belongs in code")
|
||||
assert cma._log_line(change, "2026-07-27 02:05") == '2026-07-27 02:05 DELETED [detail] "- a path" — belongs in code'
|
||||
|
||||
|
||||
def test_log_line_for_merge():
|
||||
change = cma.Change(op="merge", category="merge", original=("- a", "- b"), new_text=("- ab",), reason="one topic")
|
||||
assert cma._log_line(change, "2026-07-27 02:05") == '2026-07-27 02:05 MERGED [merge] "- a - b" → "- ab" — one topic'
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shorten_keeps_short_text():
|
||||
assert cma._shorten("short", 10) == "short"
|
||||
|
||||
|
||||
def test_shorten_truncates_with_ellipsis():
|
||||
assert cma._shorten("abcdefghij", 5) == "abcd…"
|
||||
|
||||
|
||||
def test_shorten_strips_before_the_ellipsis():
|
||||
assert cma._shorten("ab cdefgh", 4) == "ab…"
|
||||
|
||||
|
||||
def test_quote_joins_stripped_lines():
|
||||
assert cma._quote((" - first ", "- second")) == "- first - second"
|
||||
|
||||
|
||||
def test_quote_truncates_long_blocks():
|
||||
quoted = cma._quote(tuple(f"- line {i}" for i in range(30)))
|
||||
assert len(quoted) == cma.QUOTE_CHARS
|
||||
assert quoted.endswith("…")
|
||||
|
||||
|
||||
# --- config ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_telegram_config_uses_first_allowed_chat():
|
||||
config = {"channels": {"telegram": {"token": "tok", "allowFrom": [12345, 999]}}}
|
||||
assert cma._telegram_config(config) == ("tok", "12345")
|
||||
|
||||
|
||||
def test_telegram_config_falls_back_when_allow_from_is_empty():
|
||||
config = {"channels": {"telegram": {"token": "tok", "allowFrom": []}}}
|
||||
assert cma._telegram_config(config) == ("tok", cma.FALLBACK_CHAT_ID)
|
||||
|
||||
|
||||
def test_telegram_config_falls_back_when_allow_from_is_missing():
|
||||
config = {"channels": {"telegram": {"token": "tok"}}}
|
||||
assert cma._telegram_config(config) == ("tok", cma.FALLBACK_CHAT_ID)
|
||||
|
||||
|
||||
def test_workspace_from_config():
|
||||
config = {"agents": {"defaults": {"workspace": "~/custom/workspace"}}}
|
||||
assert cma._workspace(config) == Path.home() / "custom" / "workspace"
|
||||
|
||||
|
||||
def test_workspace_falls_back_when_unset():
|
||||
assert cma._workspace({}) == cma.WORKSPACE_FALLBACK
|
||||
Reference in New Issue
Block a user