diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1e9a52..f4a9fe55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed +- **Compaction boundary vs user `QUERY:` / `[` text.** + `strip_compaction_preambles` now takes the first orchestrator + `\n\nQUERY: ` wrapper (not the last) and strips only known + `[OPEN TODOS]` / `[KNOWN PITFALLS]` / `[INTENT]` envelopes. A user + turn that embeds that delimiter or starts with `[` no longer stores a + truncated boundary that fails remap after reload. - **`/schedule resume` is paused-only.** The writ no longer flips `completed`, `failed`, `canceled`, `running`, or already-`active` rows back to `active`, and `/schedule pause` refuses those terminal diff --git a/src/context_compaction.cpp b/src/context_compaction.cpp index 00015c0c..3ad0a7d5 100644 --- a/src/context_compaction.cpp +++ b/src/context_compaction.cpp @@ -108,28 +108,45 @@ std::string compaction_boundary_content(std::string_view content) { return std::string(content.substr(0, kCompactionBoundaryMax)); } -std::string strip_compaction_preambles(std::string_view content) { - std::string s(content); - // Drop [OPEN TODOS]…[END OPEN TODOS] blocks injected before the turn. +namespace { + +void erase_marked_block(std::string& s, std::string_view start_mark, + std::string_view end_mark) { for (;;) { - const auto start = s.find("[OPEN TODOS]"); + const auto start = s.find(start_mark); if (start == std::string::npos) break; - const auto end = s.find("[END OPEN TODOS]", start); + const auto end = s.find(end_mark, start); if (end == std::string::npos) break; - size_t erase_to = end + std::string_view("[END OPEN TODOS]").size(); + size_t erase_to = end + end_mark.size(); while (erase_to < s.size() && (s[erase_to] == '\n' || s[erase_to] == '\r')) ++erase_to; s.erase(start, erase_to - start); } - // Index master wraps the user line as "…\n\nQUERY: ". +} + +} // namespace + +std::string strip_compaction_preambles(std::string_view content) { + std::string s(content); + // Orchestrator injects these as whole marked blocks before the turn. + // Strip by start/end markers — not "leading '[' + first blank line" — + // so user text that starts with '[' or contains "\n\nQUERY: " survives. + erase_marked_block(s, "[OPEN TODOS]", "[END OPEN TODOS]"); + erase_marked_block(s, "[KNOWN PITFALLS", "[END KNOWN PITFALLS]"); + // Index master wraps the user line as "…\n\nQUERY: ". Use the + // first mark: a user who typed that same delimiter must keep it, or + // the stored boundary will not match the raw DB row and remap fails + // open (covered_until=0, full history + summary). static constexpr std::string_view kQuery = "\n\nQUERY: "; - const auto q = s.rfind(kQuery); + const auto q = s.find(kQuery); if (q != std::string::npos) s = s.substr(q + kQuery.size()); - // Known-pitfalls / lesson blocks end before the real user text; if a - // double newline remains after a leading bracket block, keep the tail. - if (!s.empty() && s.front() == '[') { + // Specialist ingress: "[INTENT] …\n\n" + user text. Only this known + // prefix — a user turn that begins with '[' is not a preamble. + static constexpr std::string_view kIntent = "[INTENT]"; + if (s.size() >= kIntent.size() && + s.compare(0, kIntent.size(), kIntent) == 0) { const auto sep = s.find("\n\n"); if (sep != std::string::npos) s = s.substr(sep + 2); } diff --git a/tests/test_context_compaction.cpp b/tests/test_context_compaction.cpp index 7382c5e0..d10bcb4c 100644 --- a/tests/test_context_compaction.cpp +++ b/tests/test_context_compaction.cpp @@ -212,6 +212,63 @@ TEST_CASE("strip_compaction_preambles removes todos and QUERY wrapper") { strip_compaction_preambles(raw)))); } +TEST_CASE("strip_compaction_preambles keeps user-embedded QUERY delimiter") { + // Index wrap uses the first "\n\nQUERY: ". rfind would keep only + // "docs" and fail to match the raw DB row on remap. + const std::string raw = + "AGENTS: none loaded\n" + "\n" + "QUERY: see\n\nQUERY: docs"; + CHECK(strip_compaction_preambles(raw) == "see\n\nQUERY: docs"); + CHECK(boundary_content_matches("see\n\nQUERY: docs", + compaction_boundary_content( + strip_compaction_preambles(raw)))); +} + +TEST_CASE("strip_compaction_preambles keeps user text that starts with '['") { + const std::string raw = + "[KNOWN PITFALLS — your prior lessons]\n" + " - [sig] body with a blank line\n" + "\n" + "still in the lesson (#1)\n" + "[END KNOWN PITFALLS]\n" + "\n" + "[NOTE]\n" + "\n" + "actual question"; + CHECK(strip_compaction_preambles(raw) == "[NOTE]\n\nactual question"); + CHECK(boundary_content_matches("[NOTE]\n\nactual question", + compaction_boundary_content( + strip_compaction_preambles(raw)))); +} + +TEST_CASE("strip_compaction_preambles drops specialist INTENT envelope only") { + const std::string raw = + "[INTENT] kind=implement conf=0.80 source=heuristic\n" + "GOAL: ship it\n" + "\n" + "[NOTE]\n" + "\n" + "keep this"; + CHECK(strip_compaction_preambles(raw) == "[NOTE]\n\nkeep this"); +} + +TEST_CASE("remap unique boundary survives user-embedded QUERY") { + auto hist = make_history(8); + hist[4].role = "user"; + hist[4].content = "see\n\nQUERY: docs"; + CompactionState st; + st.summary = "prior"; + st.covered_until = 99; + st.generation = 1; + st.boundary_role = "user"; + st.boundary_content = compaction_boundary_content( + strip_compaction_preambles( + "AGENTS: none loaded\n\nQUERY: see\n\nQUERY: docs")); + remap_compaction_onto_history(st, hist, /*keep=*/16); + CHECK(st.covered_until == 4); +} + TEST_CASE("remap_compaction_onto_history clears empty summary") { auto hist = make_history(20); CompactionState st;