From b7146298c0576464b26844b82a90c6f911b3d23c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 11:53:48 +0000 Subject: [PATCH] fix(agent): stub oversized history only on an empty summary Compaction treated a truncated stub as the whole message. When earlier lines had already filled the 64 KiB summary source, a later message that cannot fit was cut to the leftover sliver and then dropped. Stub that line only on an empty source so this pass summarizes the prefix and the next pass keeps about 64 KiB of the oversized line. Co-authored-by: esadrianno --- CHANGELOG.md | 1 + src/core/agent.c | 23 +++++++------ tests/test_agent.c | 82 +++++++++++++++++++++++++++++++--------------- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c20031b..6a1cdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Session compaction keeps a message that does not fit the 64 KiB summary source, and everything after it, instead of skipping the long line and deleting it anyway. - Session compaction no longer persists the summarized transcript before the user turn finishes: a failed follow-up provider call leaves the raw stored session unchanged, and when the kept tail already starts with a user turn the summary is folded into that turn so consecutive user roles cannot break the next Anthropic call (supersedes #126). - Session compaction summarizes a message larger than the summary source as a truncated `[truncated]` stub, so a single oversized line can no longer abort the pass with an empty summary source or keep the session above `max_context_messages` forever. +- Session compaction stubs that oversized line only when the summary source is still empty. A later oversized message is left in the session until the next pass, instead of being cut to whatever sliver the earlier lines left and then deleted. - WebChat `ws_send_to` drops a send whose session has no live `wsi` (still returning 0) and `ws_unregister_conn` drops frames still queued for the closing session, so ~64 abandoned replies cannot pin the shared outgoing queue at `MAX_QUEUE` (64) and stall every live client until restart. - `read_file` reads that do not fit the result buffer now fail with a JSON error instead of returning a clipped prefix, so a later `write_file` cannot persist a silently truncated file. diff --git a/src/core/agent.c b/src/core/agent.c index 22d9d2e..dd4733f 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -250,16 +250,19 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si if (cJSON_IsString(r)) role = r->valuestring; if (cJSON_IsString(c)) content = c->valuestring; if (!summary_source_append(source_buf, SUMMARY_SOURCE_MAX, &used, role, content)) { - /* A line that can never fit whole would abort with used==0 - * (first line) or keep the session above max_ctx forever - * (later line); stub it so this pass still consumes it. - * Lines that only lack room here stay raw for a later pass - * via next_unsummarized. */ - if (!summary_line_never_fits(role, content) || - !summary_source_append_truncated(source_buf, SUMMARY_SOURCE_MAX, &used, role, content)) { - next_unsummarized = i; - break; - } + /* Stub a never-fit line only against an empty source. Leftover + * room after earlier lines is a sliver: the stub still counts as + * the whole message, and the unread tail is deleted. Defer so + * this pass summarizes the prefix and the next pass stubs the + * line at the full 64 KiB. A never-fit first line still has to + * be stubbed here, or used==0 aborts compaction and the session + * never shrinks. Lines that fit a fresh source stay raw. */ + if (used == 0 && + summary_line_never_fits(role, content) && + summary_source_append_truncated(source_buf, SUMMARY_SOURCE_MAX, &used, role, content)) + continue; + next_unsummarized = i; + break; } } if (used == 0) { diff --git a/tests/test_agent.c b/tests/test_agent.c index e1e9c22..f5d3cfa 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -681,21 +681,31 @@ static int test_compaction_progresses_when_first_message_is_oversized(void) return failed; } -/* An oversized LATER line used to be kept raw with everything after it, so - * new_count stayed above max_ctx forever. The truncated stub now consumes it - * and this pass brings the session back down to max_ctx. */ +/* A later line that cannot fit a fresh 64 KiB source must not be stubbed into + * whatever sliver the earlier lines left. That stub is treated as the whole + * message, so the unread tail is deleted. The prefix is summarized first; a + * later pass stubs the oversized line against an empty source, and the + * session still shrinks back to max_ctx. */ static int test_compaction_progresses_when_later_message_is_oversized(void) { - enum { OVERSIZE = 65 * 1024, JSON_CAP = 96 * 1024, LOAD_CAP = 128 * 1024 }; + enum { + PRE_SIZE = 50 * 1024, + OVERSIZE = 65 * 1024, + JSON_CAP = 192 * 1024, + LOAD_CAP = 128 * 1024 + }; int failed = 1; + int shrunk = 0; config_t *cfg = NULL; char *session_json = NULL; char *loaded = NULL; + char *pre = NULL; char *big = NULL; const char *db_path = "build/test_agent_compact_stall_later.db"; const char *config_path = "build/test_agent_compact_stall_later.toml"; size_t off; int i; + int nmsg; char response_buf[4096]; char errbuf[256]; FILE *cf; @@ -705,17 +715,21 @@ static int test_compaction_progresses_when_later_message_is_oversized(void) if (memory_init(db_path) != 0) goto cleanup_early; session_json = malloc(JSON_CAP); + pre = malloc((size_t)PRE_SIZE + 1); big = malloc((size_t)OVERSIZE + 1); loaded = malloc(LOAD_CAP); - if (!session_json || !big || !loaded) + if (!session_json || !pre || !big || !loaded) goto cleanup; - memset(big, 'A', (size_t)OVERSIZE); + memset(pre, 'P', (size_t)PRE_SIZE); + memcpy(pre + PRE_SIZE - 7, "PRE_END", 7); + pre[PRE_SIZE] = '\0'; + memset(big, 'B', (size_t)OVERSIZE); memcpy(big + OVERSIZE - 11, "STALL_B_END", 11); big[OVERSIZE] = '\0'; off = 0; off += (size_t)snprintf(session_json + off, JSON_CAP - off, "["); off += (size_t)snprintf(session_json + off, JSON_CAP - off, - "{\"role\":\"user\",\"content\":\"alpha_small\"}"); + "{\"role\":\"user\",\"content\":\"%s\"}", pre); off += (size_t)snprintf(session_json + off, JSON_CAP - off, ",{\"role\":\"user\",\"content\":\"%s\"}", big); for (i = 2; i < 6; i++) { @@ -736,32 +750,48 @@ static int test_compaction_progresses_when_later_message_is_oversized(void) if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; response_buf[0] = '\0'; - if (agent_run(cfg, "cli:stalllater", "hi", &compaction_provider, NULL, 0, - response_buf, sizeof(response_buf)) != 0) - goto cleanup; - if (!compaction_source_truncated) { - fprintf(stderr, - "FAIL: oversized later line did not reach the summary as a truncated stub\n"); - goto cleanup; - } - if (session_load("cli:stalllater", loaded, LOAD_CAP) != 0) - goto cleanup; - if (strstr(loaded, "STALL_B_END") != NULL) { - fprintf(stderr, "FAIL: oversized later line is still raw instead of summarized\n"); - goto cleanup; - } - if (strstr(loaded, "Summary of earlier") == NULL) { - fprintf(stderr, "FAIL: expected a summary of the truncated oversized turn\n"); - goto cleanup; + for (i = 0; i < 8; i++) { + if (agent_run(cfg, "cli:stalllater", "hi", &compaction_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) + goto cleanup; + if (session_load("cli:stalllater", loaded, LOAD_CAP) != 0) + goto cleanup; + nmsg = count_session_messages(loaded); + if (i == 0) { + if (strstr(loaded, "STALL_B_END") == NULL) { + fprintf(stderr, + "FAIL: oversized later line was deleted after a %d-byte predecessor filled the summary\n", + PRE_SIZE); + goto cleanup; + } + if (strstr(loaded, "PRE_END") != NULL) { + fprintf(stderr, + "FAIL: predecessor that fit the summary was kept raw\n"); + goto cleanup; + } + if (strstr(loaded, "Summary of earlier") == NULL) { + fprintf(stderr, + "FAIL: expected a summary of the predecessor, not a sliver of the oversized line\n"); + goto cleanup; + } + } + if (strstr(loaded, "STALL_B_END") == NULL && nmsg <= 4 + 2) { + shrunk = 1; + break; + } } - if (count_session_messages(loaded) > 4 + 2) { - fprintf(stderr, "FAIL: session is not back to max_ctx after the oversized turn\n"); + if (!shrunk) { + nmsg = count_session_messages(loaded); + fprintf(stderr, + "FAIL: oversized later line never left the session or count stayed at %d\n", + nmsg); goto cleanup; } failed = 0; cleanup: config_free(cfg); free(session_json); + free(pre); free(big); free(loaded); cleanup_early: