While investigating a user report ("Model losing all chat history instead of compacting"), I found two verified mechanisms in the compaction paths that replace the model's entire context with nothing. Both reproduce against the current code; repro details below.
1. /compact with an empty model response wipes the history
packages/agent-runtime/src/run-agent-step.ts:562-574 replaces the whole message history with a single summary message built from fullResponse:
agentState.messageHistory = [
userMessage(withSystemTags(`The following is a summary of the conversation between you and the user. ...:\n\n${fullResponse}`)),
]
There is no check that fullResponse is non-empty. A silent empty stop (finish part with a real reason and usage but no content) yields no recovery chunk — classifyStreamEndRecovery returns null for it (sdk/src/impl/stream-interruption.ts) — so nothing catches it. I reproduced the full shape with a mocked stream: fullResponse === "", zero recovery/error chunks, and the replacement message is 'The following is a summary of the conversation...:\n\n' — i.e. the model's next turn starts with an empty summary and no earlier turns. Near the context limit (exactly when a user reaches for /compact) empty completions are also the most likely, so the trigger lines up with the report.
2. A user message quoting the summary markers steals the summary's identity and erases the older memory
compactMessages (packages/agent-runtime/src/compact-history.ts) recognizes its own summary by CONTENT: a user-role message containing <conversation_summary> and the header sentence. Two consequences:
previousSummary = messages.findLast(isConversationSummary) — the LAST match wins.
isRealHistory excludes anything matching, so the matched message is never summarized.
So a later user message that merely quotes both markers (asking about this very mechanism, pasting a summary back) is taken for the real summary; extractSummaryContent on the quote returns '' (no closing tag) or the quoted text; the REAL summary is neither re-parsed nor kept as history. Every earlier turn vanishes from the model's context at that compaction. Reproduced with a 3-generation compactMessages chain: generation 3's summary retained none of generations 1-2 once the quoting message was present (previous_summary_entry_count: 0).
The inlined copy in agents/context-pruner.ts (still live for the base2 family — it is spawned before every step there) matches on the bare tag ALONE, so a user message that merely mentions <conversation_summary> triggers the same wipe there, and set_messages adopts it with no validation (packages/agent-runtime/src/tools/handlers/tool/set-messages.ts:20).
Suggested fix
- Guard the
/compact replacement: only replace when fullResponse.trim() is non-empty; otherwise keep the history (the forced next step retries the summary anyway) and log.
- Identify summaries by provenance, not content: stamp the summary message with a
CONVERSATION_SUMMARY tag in both buildSummaryMessage (compact-history.ts) and the pruner's copy, and have isConversationSummary require the tag, with a legacy fallback that requires the FULL envelope (open+close tag, header, <historical_memory>) so pre-tag summaries still fold in. A user message can then never gain summary identity by content alone. (The parity test's deliberate divergence — pruner matching the bare tag — closes too.)
Happy to open a PR with both, plus regression tests.
Related (same symptom family, separate cause, not covered by the fix above)
On desktop/CLI resume, an unreadable run-state.json falls back to a RunState with no sessionState (cli/src/utils/run-state-storage.ts:518-549), so the UI transcript survives but the model starts amnesiac next turn — worth checking in telemetry if reports continue after the compaction fixes.
While investigating a user report ("Model losing all chat history instead of compacting"), I found two verified mechanisms in the compaction paths that replace the model's entire context with nothing. Both reproduce against the current code; repro details below.
1.
/compactwith an empty model response wipes the historypackages/agent-runtime/src/run-agent-step.ts:562-574replaces the whole message history with a single summary message built fromfullResponse:There is no check that
fullResponseis non-empty. A silent empty stop (finish part with a real reason and usage but no content) yields no recovery chunk —classifyStreamEndRecoveryreturns null for it (sdk/src/impl/stream-interruption.ts) — so nothing catches it. I reproduced the full shape with a mocked stream:fullResponse === "", zero recovery/error chunks, and the replacement message is'The following is a summary of the conversation...:\n\n'— i.e. the model's next turn starts with an empty summary and no earlier turns. Near the context limit (exactly when a user reaches for/compact) empty completions are also the most likely, so the trigger lines up with the report.2. A user message quoting the summary markers steals the summary's identity and erases the older memory
compactMessages(packages/agent-runtime/src/compact-history.ts) recognizes its own summary by CONTENT: a user-role message containing<conversation_summary>and the header sentence. Two consequences:previousSummary = messages.findLast(isConversationSummary)— the LAST match wins.isRealHistoryexcludes anything matching, so the matched message is never summarized.So a later user message that merely quotes both markers (asking about this very mechanism, pasting a summary back) is taken for the real summary;
extractSummaryContenton the quote returns''(no closing tag) or the quoted text; the REAL summary is neither re-parsed nor kept as history. Every earlier turn vanishes from the model's context at that compaction. Reproduced with a 3-generationcompactMessageschain: generation 3's summary retained none of generations 1-2 once the quoting message was present (previous_summary_entry_count: 0).The inlined copy in
agents/context-pruner.ts(still live for the base2 family — it is spawned before every step there) matches on the bare tag ALONE, so a user message that merely mentions<conversation_summary>triggers the same wipe there, andset_messagesadopts it with no validation (packages/agent-runtime/src/tools/handlers/tool/set-messages.ts:20).Suggested fix
/compactreplacement: only replace whenfullResponse.trim()is non-empty; otherwise keep the history (the forced next step retries the summary anyway) and log.CONVERSATION_SUMMARYtag in bothbuildSummaryMessage(compact-history.ts) and the pruner's copy, and haveisConversationSummaryrequire the tag, with a legacy fallback that requires the FULL envelope (open+close tag, header,<historical_memory>) so pre-tag summaries still fold in. A user message can then never gain summary identity by content alone. (The parity test's deliberate divergence — pruner matching the bare tag — closes too.)Happy to open a PR with both, plus regression tests.
Related (same symptom family, separate cause, not covered by the fix above)
On desktop/CLI resume, an unreadable
run-state.jsonfalls back to a RunState with no sessionState (cli/src/utils/run-state-storage.ts:518-549), so the UI transcript survives but the model starts amnesiac next turn — worth checking in telemetry if reports continue after the compaction fixes.