Fix: Restore fallback preserves conversation history#364
Conversation
When ResumeSessionAsync fails with 'Session not found' / 'corrupt' / 'session file' errors, the fallback path was calling CreateSessionAsync without recovering history from the old session's events.jsonl. This caused sessions to appear empty after app relaunch. The fix adds history recovery to the fallback path: 1. Load history from old session via LoadHistoryFromDisk(entry.SessionId) 2. Inject recovered messages into the new session's Info.History 3. Set MessageCount and LastReadMessageCount 4. Call RestoreUsageStats(entry) to preserve CreatedAt, token counts 5. Sync recovered history to chat DB under the new session ID 6. Add a system message indicating the session was recreated Bug introduced in PR #225 (commit 19219f1), worsened by PR #308 (commit 72886a2) which expanded the catch conditions without adding history recovery. Adds 5 structural regression tests guarding the fallback path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sonl checks Three additional bugs causing session history loss on restore: 1. RECONNECT handler didn't call SaveActiveSessionsToDisk() after updating SessionId, so the debounced save wrote stale IDs. On next restart, the stale ID's directory exists but has no events.jsonl. 2. MergeSessionEntries only deduped by SessionId (case-insensitive). After RECONNECT changes the ID, old persisted entries with the stale ID pass the check (stale != new), accumulating N+1 ghost entries per restart cycle. 3. Directory existence checks (Directory.Exists) were too lenient — the SDK creates empty session directories during ResumeSessionAsync. Changed to require events.jsonl file presence in both WriteActiveSessionsFile and RestorePreviousSessionsAsync. Tests: 9 new regression tests (display name dedup, reconnect save guard, events.jsonl structural checks). Entry helper default changed from shared name to id-based to avoid false dedup in existing tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- MergeSessionEntries: only dedup persisted entries against active names, not against each other (fixes legitimate same-name session loss) - WriteActiveSessionsFile: accept sessions with events.jsonl OR recently created dirs (< 5 min), drop stale ghost directories - RestorePreviousSessionsAsync: remove early events.jsonl skip so never-used sessions reach the existing fallback path - Fallback path: copy old events.jsonl to new session dir for durability across future restarts - Fallback path: normalize stale incomplete ToolCall/Reasoning entries (matching ResumeSessionAsync behavior) - Add 5 behavioral filesystem tests for sessionDirExists callback logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace GC-based UnobservedTaskException detection with direct task verification. The old approach used TaskScheduler.UnobservedTaskException (a process-global handler) + forced GC, which was susceptible to cross-test pollution — other tests' leaked exceptions could surface during this test's GC cycles. New approach: await the fire-and-forget tasks, assert they completed without faulting (proving internal exception handling works), and verify the error return value (-1). Deterministic, no GC timing dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CRITICAL: Replace File.Copy with sanitized line-by-line copy during fallback recovery. If events.jsonl caused ResumeSessionAsync to fail (corrupt JSON), a raw copy propagates corruption to the new session directory, creating a retry loop on every restart. Now each line is validated via JsonDocument.Parse — only valid JSON lines are written. MODERATE: Guard against null DisplayName in MergeSessionEntries' activeNames HashSet. StringComparer.OrdinalIgnoreCase.GetHashCode(null) throws ArgumentNullException. JSON deserialization can produce null DisplayName even though the default is empty string. Added .Where(n => n != null) filter. Tests: - Merge_NullDisplayNameInActive_DoesNotThrow - Merge_NullDisplayNameInPersisted_DoesNotThrow - WriteActiveSessionsFile_SanitizedCopy_Concept - SanitizedCopy_WritesOnlyValidJsonLines - Updated structural tests: widened search window (5000→7000 chars), updated File.Copy assertion to match sanitized copy comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #364 Review — Multi-Agent Consensus ReportPR: Fix: Restore fallback preserves conversation history Consensus Findings (2+ models agree)🔴 CRITICAL — Corrupt events.jsonl propagated via
|
- Move MessageCount/LastReadMessageCount assignment AFTER system message injection so the '🔄 Session recreated' message is included in the count and triggers the unread indicator - Remove duplicate pre-system-message MessageCount assignment - Clarify SaveActiveSessionsToDisk comment: it's debounced (2s), not immediate. The fallback path handles the crash-within-window case. - Add structural regression test verifying MessageCount is set after the system message, not before Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
---------- Forwarded message ---------
От: Shane Neuville ***@***.***>
Date: чт, 12 мар. 2026 г., 23:35
Subject: [PureWeen/PolyPilot] Fix: Restore fallback preserves conversation
history (PR #364)
To: PureWeen/PolyPilot ***@***.***>
Cc: Subscribed ***@***.***>
Problem
When ResumeSessionAsync fails during app restart ("Session not found" /
"corrupt" / "session file" errors), the fallback path called
CreateSessionAsync which created a *blank* session with zero messages. The
old session's events.jsonl was never loaded, so all conversation history
was lost.
*User impact:* Sessions appeared empty after app relaunch — the user would
see their session restored but with 0 messages, forcing them to create
duplicate sessions.
Root Cause
Introduced in PR #225 <#225>
(commit 19219f1) which added the "Session not found" fallback. Worsened in
PR #308 <#308> which expanded the
catch conditions to include "corrupt" and "session file" errors. Neither PR
added history recovery to the fallback path.
ResumeSessionAsync (the success path) correctly loads history via
LoadHistoryFromDisk(sessionId). The fallback path skipped this entirely.
Fix
In the fallback path of RestorePreviousSessionsAsync:
1. *Load history* from the old session's events.jsonl via
LoadHistoryFromDisk(entry.SessionId) before creating the new session
2. *Inject history* into the recreated session's Info.History
3. *Set message counts* (MessageCount, LastReadMessageCount) so the UI
shows the correct state
4. *Restore usage stats* (CreatedAt, token counts) via
RestoreUsageStats(entry)
5. *Sync to DB* via BulkInsertAsync under the new session ID
6. *Add indicator* system message: "🔄 Session recreated — conversation
history recovered from previous session."
Duplicate Session Issue
The user also observed a duplicate session (session-20260311-001729)
created with the same repo as the original. This was a downstream
consequence: after seeing the original session restored with zero messages,
the user manually created a new session. With this fix, the full history is
preserved, eliminating the need for duplicates.
Tests
Added 5 structural regression tests in SessionPersistenceTests.cs:
- RestoreFallback_LoadsHistoryFromOldSession — verifies
LoadHistoryFromDisk appears before CreateSessionAsync
- RestoreFallback_InjectsHistoryIntoRecreatedSession — verifies history
injection + message count
- RestoreFallback_RestoresUsageStats — verifies RestoreUsageStats call
- RestoreFallback_SyncsHistoryToDatabase — verifies BulkInsertAsync call
- RestoreFallback_AddsReconnectionIndicator — verifies system message
All 2464 tests pass.
------------------------------
You can view, comment on, or merge this pull request online at:
#364
Commit Summary
- 7317b63
<7317b63>
Fix: Restore fallback preserves conversation history
File Changes
(2 files <https://github.com/PureWeen/PolyPilot/pull/364/files>)
- *M* PolyPilot.Tests/SessionPersistenceTests.cs
<https://github.com/PureWeen/PolyPilot/pull/364/files#diff-82798322386bd26094c2c24f37e9f4f9ec63e27fe627b1d11024ee68c21209fd>
(95)
- *M* PolyPilot/Services/CopilotService.Persistence.cs
<https://github.com/PureWeen/PolyPilot/pull/364/files#diff-c221c32d009ca0183dbee5ee195bb36b4b6c7a53df116c572a8f241dbb3c7db1>
(27)
Patch Links:
- https://github.com/PureWeen/PolyPilot/pull/364.patch
- https://github.com/PureWeen/PolyPilot/pull/364.diff
—
Reply to this email directly, view it on GitHub
<#364>, or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B4FTARGFXN7NLXGWZ6E3KJT4QMNRXAVCNFSM6AAAAACWQFDIWKVHI2DSMVQWIX3LMV43ASLTON2WKOZUGA3DOMJQG43TKNI>
.
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
## Problem
When `ResumeSessionAsync` fails during app restart ("Session not found"
/ "corrupt" / "session file" errors), the fallback path called
`CreateSessionAsync` which created a **blank** session with zero
messages. The old session's `events.jsonl` was never loaded, so all
conversation history was lost.
**User impact:** Sessions appeared empty after app relaunch — the user
would see their session restored but with 0 messages, forcing them to
create duplicate sessions.
## Root Cause
Introduced in PR PureWeen#225 (commit `19219f1`) which added the "Session not
found" fallback. Worsened in PR PureWeen#308 which expanded the catch conditions
to include "corrupt" and "session file" errors. Neither PR added history
recovery to the fallback path.
`ResumeSessionAsync` (the success path) correctly loads history via
`LoadHistoryFromDisk(sessionId)`. The fallback path skipped this
entirely.
## Fix
In the fallback path of `RestorePreviousSessionsAsync`:
1. **Load history** from the old session's `events.jsonl` via
`LoadHistoryFromDisk(entry.SessionId)` before creating the new session
2. **Inject history** into the recreated session's `Info.History`
3. **Set message counts** (`MessageCount`, `LastReadMessageCount`) so
the UI shows the correct state
4. **Restore usage stats** (`CreatedAt`, token counts) via
`RestoreUsageStats(entry)`
5. **Sync to DB** via `BulkInsertAsync` under the new session ID
6. **Add indicator** system message: "🔄 Session recreated — conversation
history recovered from previous session."
## Duplicate Session Issue
The user also observed a duplicate session (`session-20260311-001729`)
created with the same repo as the original. This was a downstream
consequence: after seeing the original session restored with zero
messages, the user manually created a new session. With this fix, the
full history is preserved, eliminating the need for duplicates.
## Tests
Added 5 structural regression tests in `SessionPersistenceTests.cs`:
- `RestoreFallback_LoadsHistoryFromOldSession` — verifies
`LoadHistoryFromDisk` appears before `CreateSessionAsync`
- `RestoreFallback_InjectsHistoryIntoRecreatedSession` — verifies
history injection + message count
- `RestoreFallback_RestoresUsageStats` — verifies `RestoreUsageStats`
call
- `RestoreFallback_SyncsHistoryToDatabase` — verifies `BulkInsertAsync`
call
- `RestoreFallback_AddsReconnectionIndicator` — verifies system message
All 2464 tests pass.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem
When
ResumeSessionAsyncfails during app restart ("Session not found" / "corrupt" / "session file" errors), the fallback path calledCreateSessionAsyncwhich created a blank session with zero messages. The old session'sevents.jsonlwas never loaded, so all conversation history was lost.User impact: Sessions appeared empty after app relaunch — the user would see their session restored but with 0 messages, forcing them to create duplicate sessions.
Root Cause
Introduced in PR #225 (commit
19219f1) which added the "Session not found" fallback. Worsened in PR #308 which expanded the catch conditions to include "corrupt" and "session file" errors. Neither PR added history recovery to the fallback path.ResumeSessionAsync(the success path) correctly loads history viaLoadHistoryFromDisk(sessionId). The fallback path skipped this entirely.Fix
In the fallback path of
RestorePreviousSessionsAsync:events.jsonlviaLoadHistoryFromDisk(entry.SessionId)before creating the new sessionInfo.HistoryMessageCount,LastReadMessageCount) so the UI shows the correct stateCreatedAt, token counts) viaRestoreUsageStats(entry)BulkInsertAsyncunder the new session IDDuplicate Session Issue
The user also observed a duplicate session (
session-20260311-001729) created with the same repo as the original. This was a downstream consequence: after seeing the original session restored with zero messages, the user manually created a new session. With this fix, the full history is preserved, eliminating the need for duplicates.Tests
Added 5 structural regression tests in
SessionPersistenceTests.cs:RestoreFallback_LoadsHistoryFromOldSession— verifiesLoadHistoryFromDiskappears beforeCreateSessionAsyncRestoreFallback_InjectsHistoryIntoRecreatedSession— verifies history injection + message countRestoreFallback_RestoresUsageStats— verifiesRestoreUsageStatscallRestoreFallback_SyncsHistoryToDatabase— verifiesBulkInsertAsynccallRestoreFallback_AddsReconnectionIndicator— verifies system messageAll 2464 tests pass.