fix(agents): preserve re-sent user prompt during compaction transcript rotation#93732
Conversation
…otation Duplicate user-message detection ran over the full branch, so when a prompt was re-sent within the 60s window its earlier copy in the summarized prefix and the later copy in the kept tail were both removed: the summarized copy via summarizedBranchIds and the kept copy as a duplicate. With truncateAfterCompaction enabled the prompt then vanished from the successor transcript entirely. Restrict dedup to the kept region so the first surviving copy is preserved.
cf41dd9 to
0ccc8f2
Compare
|
Codex review: needs maintainer review before merge. Reviewed June 16, 2026, 2:41 PM ET / 18:41 UTC. Summary PR surface: Source +2, Tests +79. Total +81 across 2 files. Reproducibility: yes. source-reproducible: current main removes summarized-prefix messages and duplicate ids computed from the full branch, so a first copy in the summarized prefix and a re-sent kept-tail copy can both disappear. I did not execute tests because this review was constrained to read-only inspection. Review metrics: none identified. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the narrow caller-side filter with regression coverage after normal CI/maintainer review, keeping duplicate pruning semantics unchanged for duplicate retries that both survive in the successor transcript. Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible: current main removes summarized-prefix messages and duplicate ids computed from the full branch, so a first copy in the summarized prefix and a re-sent kept-tail copy can both disappear. I did not execute tests because this review was constrained to read-only inspection. Is this the best way to solve the issue? Yes; filtering the duplicate collector input in buildSuccessorEntries is the best narrow boundary because the helper still drops true duplicates inside the kept region and the separate compaction-input dedupe path remains unchanged. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 617c9d4b7fb4. Label changesLabel justifications:
Evidence reviewedPR surface: Source +2, Tests +79. Total +81 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
Maintainer verification complete. Whole-path review confirms that anchoring duplicate collection to the kept compaction branch is the best fix: it preserves the re-sent request without changing summarized-prefix or within-tail dedup behavior. Verified:
Ready to squash merge. |
…otation (openclaw#93732) Duplicate user-message detection ran over the full branch, so when a prompt was re-sent within the 60s window its earlier copy in the summarized prefix and the later copy in the kept tail were both removed: the summarized copy via summarizedBranchIds and the kept copy as a duplicate. With truncateAfterCompaction enabled the prompt then vanished from the successor transcript entirely. Restrict dedup to the kept region so the first surviving copy is preserved.
…otation (openclaw#93732) Duplicate user-message detection ran over the full branch, so when a prompt was re-sent within the 60s window its earlier copy in the summarized prefix and the later copy in the kept tail were both removed: the summarized copy via summarizedBranchIds and the kept copy as a duplicate. With truncateAfterCompaction enabled the prompt then vanished from the successor transcript entirely. Restrict dedup to the kept region so the first surviving copy is preserved.
Under truncateAfterCompaction, buildSuccessorEntries ran one 60s duplicate-user dedup window across both the kept pre-compaction tail and all post-compaction entries. A kept-tail prompt became the dedup first-seen and suppressed a genuine post-compaction re-issue of the same text, deleting the user instruction and orphaning its assistant reply (two consecutive assistant messages with no parent user turn), which strict providers reject on the next request. Dedup the pre-compaction kept tail and the post-compaction tail independently so the window resets at the compaction boundary. Each side still drops its own intra-window retries; a kept-tail prompt no longer reaches across the boundary. Regression of openclaw#93732, which narrowed the dedup input to keptBranchEntries but left it spanning the boundary.
Under truncateAfterCompaction, buildSuccessorEntries ran one 60s duplicate-user dedup window across both the kept pre-compaction tail and all post-compaction entries. A kept-tail prompt became the dedup first-seen and suppressed a genuine post-compaction re-issue of the same text, deleting the user instruction and orphaning its assistant reply (two consecutive assistant messages with no parent user turn), which strict providers reject on the next request. Dedup the pre-compaction kept tail and the post-compaction tail independently so the window resets at the compaction boundary. Each side still drops its own intra-window retries; a kept-tail prompt no longer reaches across the boundary. Regression of openclaw#93732, which narrowed the dedup input to keptBranchEntries but left it spanning the boundary.
Under truncateAfterCompaction, buildSuccessorEntries ran one 60s duplicate-user dedup window across both the kept pre-compaction tail and all post-compaction entries. A kept-tail prompt became the dedup first-seen and suppressed a genuine post-compaction re-issue of the same text, deleting the user instruction and orphaning its assistant reply (two consecutive assistant messages with no parent user turn), which strict providers reject on the next request. Dedup the pre-compaction kept tail and the post-compaction tail independently so the window resets at the compaction boundary. Each side still drops its own intra-window retries; a kept-tail prompt no longer reaches across the boundary. Regression of openclaw#93732, which narrowed the dedup input to keptBranchEntries but left it spanning the boundary.
Summary
With
agents.defaults.compaction.truncateAfterCompactionenabled, a compaction can silently drop the user's most recent request from the successor transcript. The agent then continues having lost what the user asked for.The trigger is a re-sent prompt: the user sends prompt
P, the agent responds, the conversation continues, and the user re-sends an identicalP(>=24 chars, within the 60s window). When compaction then fires with the boundary falling between the two copies, both copies are removed andPnever appears in the successor.Root cause
buildSuccessorEntriesinsrc/agents/embedded-agent-runner/compaction-successor-transcript.tscollects ids to drop:collectDuplicateUserMessageEntryIdsForCompaction(src/agents/embedded-agent-runner/compaction-duplicate-user-messages.ts) keeps the first occurrence of a user message and flags later same-window repeats. Running it over the fullbranchmeans: whenP's first copy lives in the summarized prefix and the later copy lives in the kept tail, the helper treats the kept-tail copy as the duplicate and adds it toduplicateUserMessageIds.That kept-tail copy is then removed via
duplicateUserMessageIds, while the earlier copy is independently removed viasummarizedBranchIds(summarized prefix message). Both verbatim instances are gone. The compaction summary is generic prose that does not contain the prompt text, so the request is lost entirely.Fix
Anchor dedup within the kept region only. The summarized prefix is already dropped via
summarizedBranchIds, so the dedup pass should not consider those entries when deciding which kept-tail copy is the "first" surviving instance:Now the first surviving copy in the kept tail is preserved; only genuine later repeats within the kept region are still dropped, which is exactly the deduplication the helper was added for.
Why this is the right boundary
buildSuccessorEntriesfeeds the helper, not in the helper itself.collectDuplicateUserMessageEntryIdsForCompactioncorrectly keeps the first occurrence and drops later repeats; it was just being shown entries (the summarized prefix) that are already removed by a different rule, so its "first occurrence" anchored on a copy that no longer survives. Filtering the input to the kept region fixes the input contract without changing the helper's semantics.Verification
node scripts/run-vitest.mjs src/agents/embedded-agent-runner/compaction-successor-transcript.duplicate-prompt-loss.test.ts-> 1 passed. This new regression test fails on pristinemain(expected false to be true) and passes with this patch.node scripts/run-vitest.mjs src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts-> existing 12 tests still pass.node scripts/run-vitest.mjs src/agents/embedded-agent-runner/compaction-duplicate-user-messages.test.ts-> 3 tests still pass.oxfmt --checkon the changed files -> clean.run-oxlint.mjson the changed files -> 0 findings.tsgocore (tsconfig.core.json) and core test (tsconfig.core.test.json) -> clean.Real behavior proof
Behavior addressed: with
truncateAfterCompactionenabled, a re-sent user prompt whose earlier copy lands in the summarized prefix and whose later copy lands in the kept tail is no longer dropped from the successor transcript.Real environment tested: drove the real production function
rotateTranscriptAfterCompactionend to end against a realSessionManager, building the transcript (prompt, assistant, "go on", assistant, compaction at that boundary, re-sent identical prompt, assistant), then read the written successor file back with the realreadTranscriptFileStateand extracted the successor's user turns. Run against pristinemainand the patched branch with identical inputs.Exact steps or command run after this patch: append the two-copy transcript through
SessionManager, callrotateTranscriptAfterCompaction({ sessionManager, sessionFile }), load the resulting successor file, and list therole === "user"turn texts.Evidence after fix:
Observed result after fix: the successor transcript that previously contained only
"go on"(the actual request"Please refactor the authentication module right now"absent) now retains the request.What was not tested: a live gateway run that triggers real model-driven compaction with
truncateAfterCompactionset; this change is at the transcript-rotation layer and is covered above by driving the real rotation/read functions plus the colocated regression test.