From 5eaae2eede6c0c122ea285c924532aad22af19d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Nov 2026 11:54:39 +0000 Subject: [PATCH] fix(agent): keep history when the compaction summary is blank A summarizer HTTP 200 with empty or whitespace content passed the NULL check and replaced the summarized turns. The follow-up chat then succeeded and session_save stored that loss. Treat a blank summary as a failed compaction and leave the loaded transcript unchanged. Co-authored-by: esadrianno --- CHANGELOG.md | 3 ++ src/core/agent.c | 23 ++++++++++ tests/test_agent.c | 105 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0252119..9e52f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] +### Fixed +- Session compaction treats a blank summarizer reply (empty or whitespace) as a failed summary and leaves the stored transcript unchanged. An empty completion, including JSON `null` content that OpenAI-compatible parsers turn into `""`, no longer deletes the turns the summary was supposed to keep. + ## [1.0.1] - 2026-10-18 ### Fixed diff --git a/src/core/agent.c b/src/core/agent.c index a3df259..d45a5e9 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -228,6 +228,22 @@ static int append_summary_user(cJSON *arr, const char *summary) return 0; } +/** True when the summarizer returned no usable text. + * OpenAI-compatible parsers turn JSON null content into "", and a local + * model can end the completion immediately. That is not a summary. */ +static int summary_text_is_blank(const char *text) +{ + const unsigned char *p; + + if (!text) + return 1; + for (p = (const unsigned char *)text; *p != '\0'; p++) { + if (*p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') + return 0; + } + return 1; +} + /** Summarize oldest messages when over max_ctx; replace with one summary + trailing. * Messages that do not fit in the summary source stay in the session. */ static int compact_session_via_llm(const char *session_id, char *session_buf, size_t session_buf_size, @@ -310,6 +326,13 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si memcpy(summary_buf, resp.content, sum_len); summary_buf[sum_len] = '\0'; provider_response_clear(&resp); + /* A blank body used to pass !resp.content and replace the summarized + * turns with an empty user line. The follow-up chat still succeeds, so + * agent_persist_session then writes that loss. Leave session_buf as loaded. */ + if (summary_text_is_blank(summary_buf)) { + cJSON_Delete(root); + return -1; + } cJSON *new_arr = cJSON_CreateArray(); int keep = max_ctx - 1; int start = (next_unsummarized < to_summarize_n) diff --git a/tests/test_agent.c b/tests/test_agent.c index 8be45d8..613e79b 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -1628,6 +1628,110 @@ static int test_compaction_failure_keeps_original_session(void) return failed; } +static int blank_summary_calls; +static const char *blank_summary_body; +static int blank_summary_init(const config_t *cfg) +{ + (void)cfg; + blank_summary_calls = 0; + return 0; +} +static int blank_summary_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) +{ + (void)tools; + (void)tool_count; + response->tool_calls = NULL; + response->tool_calls_count = 0; + response->error = 0; + blank_summary_calls++; + /* Summarizer shape is [system, source]. A blank body is what OpenAI-compatible + * parsers produce for JSON null content, and what a local model emits on EOS. */ + if (message_count == 2 && messages && messages[0].role && + strcmp(messages[0].role, "system") == 0) { + response->content = strdup(blank_summary_body ? blank_summary_body : ""); + return response->content ? 0 : -1; + } + response->content = strdup("blank-summary-ok"); + return response->content ? 0 : -1; +} +static void blank_summary_cleanup(void) {} +static const provider_t blank_summary_provider = { + .name = "blank_summary", + .init = blank_summary_init, + .chat = blank_summary_chat, + .cleanup = blank_summary_cleanup, +}; + +/* A blank summarizer completion used to count as success and replace the + * summarized turns with an empty user line. The follow-up chat still succeeds, + * so the wiped transcript is what gets saved. */ +static int blank_summary_case(const char *body, const char *session_id, const char *db_path) +{ + int failed = 1; + config_t *cfg = NULL; + char *loaded = NULL; + char config_path[128]; + char response_buf[4096]; + char errbuf[256]; + FILE *cf; + + snprintf(config_path, sizeof(config_path), "%s.toml", db_path); + memory_cleanup(); + blank_summary_calls = 0; + blank_summary_body = body; + if (memory_init(db_path) != 0) + goto cleanup_early; + if (save_alternating_session(session_id, 50) != 0) + goto cleanup; + cf = fopen(config_path, "w"); + if (!cf) + goto cleanup; + fprintf(cf, "[agent]\nmodel = \"test\"\nmax_context_messages = 40\n[memory]\npath = \"%s\"\n", + db_path); + fclose(cf); + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) + goto cleanup; + loaded = malloc(128 * 1024); + if (!loaded) + goto cleanup; + response_buf[0] = '\0'; + if (agent_run(cfg, session_id, "hi", &blank_summary_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) + goto cleanup; + if (blank_summary_calls < 2) + goto cleanup; + if (strstr(response_buf, "blank-summary-ok") == NULL) + goto cleanup; + if (session_load(session_id, loaded, 128 * 1024) != 0) + goto cleanup; + if (strstr(loaded, "\"content\":\"msg_0\"") == NULL) { + fprintf(stderr, "FAIL: blank summary \"%s\" deleted stored history\n", body); + goto cleanup; + } + if (strstr(loaded, "blank-summary-ok") == NULL || strstr(loaded, "\"content\":\"hi\"") == NULL) { + fprintf(stderr, "FAIL: blank summary \"%s\" dropped the completed turn\n", body); + goto cleanup; + } + failed = 0; +cleanup: + config_free(cfg); + free(loaded); +cleanup_early: + remove(config_path); + remove(db_path); + memory_cleanup(); + return failed; +} + +static int test_blank_compaction_summary_keeps_stored_history(void) +{ + if (blank_summary_case("", "cli:compactblank", "build/test_agent_compact_blank.db") != 0) + return 1; + return blank_summary_case(" \n\t", "cli:compactblankws", + "build/test_agent_compact_blank_ws.db"); +} + /* Odd max_context_messages cuts the tail on a user turn. A separate summary * user message then makes every later Anthropic call a 400. */ static int test_compaction_merges_summary_when_tail_starts_with_user(void) @@ -2450,6 +2554,7 @@ int main(void) RUN(test_compaction_folds_assistant_line_that_fits_a_fresh_source()); RUN(test_compaction_progresses_when_assistant_message_is_oversized()); RUN(test_compaction_failure_keeps_original_session()); + RUN(test_blank_compaction_summary_keeps_stored_history()); RUN(test_compaction_merges_summary_when_tail_starts_with_user()); RUN(test_local_offline_note_when_active_is_local()); RUN(test_local_offline_note_skipped_for_non_local());