Skip to content

feat: Import all CLI sessions into PolyPilot#825

Closed
PureWeen wants to merge 1 commit intomainfrom
feature/import-cli-sessions
Closed

feat: Import all CLI sessions into PolyPilot#825
PureWeen wants to merge 1 commit intomainfrom
feature/import-cli-sessions

Conversation

@PureWeen
Copy link
Copy Markdown
Owner

@PureWeen PureWeen commented May 1, 2026

Summary

Adds a bulk import feature to bring all sessions from ~/.copilot/session-state/ into PolyPilot's session management. This addresses the gap where the GitHub CLI app creates sessions that PolyPilot doesn't know about.

Changes

Import Engine (CopilotService.Persistence.cs)

  • ImportCliSessionsAsync() — Scans all session directories, reads only workspace.yaml (fast, no events.jsonl parsing), creates lightweight placeholder sessions
  • GenerateImportDisplayName() — Derives display names from summary → cwd basename → short GUID, with dedup suffixes
  • LoadImportedSessionHistoryAsync() — Lazy history loader, only reads events.jsonl when user actually opens the session

Performance Design

  • Import reads only workspace.yaml per session (not events.jsonl) — keeps import fast even for 4700+ sessions
  • Imported sessions skip LoadBestHistoryAsync() during RestorePreviousSessionsAsync — startup time unchanged
  • History loads lazily on SwitchSession() (user clicks) and EnsureSessionConnectedAsync() (user sends message)

Data Model

  • SessionState.IsImported flag tracks imported sessions
  • ActiveSessionEntry.Imported persists the flag across restarts
  • Sessions grouped into "Imported from CLI" group

UI (SessionSidebar.razor)

  • "📥 Import All CLI Sessions" button in the Saved Sessions section
  • Progress reporting during import

Build Fixes (bonus)

  • Skip Xcode version validation (ValidateXcodeVersion=false)
  • Add NSBluetoothAlwaysUsageDescription to MacCatalyst/iOS plists (Xcode 26.4 requirement)

Testing

  • 12 new tests in ImportCliSessionsTests.cs
  • All 3682 tests pass
  • App launches and runs successfully after relaunch

Add bulk import feature to bring all sessions from ~/.copilot/session-state/
into PolyPilot's session management:

- ImportCliSessionsAsync() scans workspace.yaml files for lightweight metadata
  (no expensive events.jsonl parsing) and creates placeholder sessions
- Imported sessions use lazy history loading: events.jsonl is only parsed
  when the user opens the session (SwitchSession) or sends a message
  (EnsureSessionConnectedAsync)
- Sessions grouped into 'Imported from CLI' group with deduped display names
- IsImported flag on SessionState + Imported property on ActiveSessionEntry
  persists across app restarts
- RestorePreviousSessionsAsync fast-path skips LoadBestHistoryAsync for
  imported sessions to keep startup performance unchanged
- UI: '📥 Import All CLI Sessions' button in Saved Sessions section with
  progress reporting

Also fixes:
- Skip Xcode version validation (ValidateXcodeVersion=false in csproj)
- Add NSBluetoothAlwaysUsageDescription to MacCatalyst/iOS Info.plist
  (required by Xcode 26.4's CoreBluetooth TCC check at launch)

Tests: 12 new tests for GenerateImportDisplayName, ActiveSessionEntry
Imported flag serialization, and merge behavior. All 3682 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review: 8 findings (1 critical, 4 moderate, 3 minor) posted as inline comments and summary. See summary comment below for full details including overflow findings outside the diff.

Generated by Expert Code Review (auto) for issue #825 · ● 31.6M

Comment on lines +1602 to +1605

// Create or get the "Imported from CLI" group
const string importedGroupName = "Imported from CLI";
var importedGroup = Organization.Groups.FirstOrDefault(g => g.Name == importedGroupName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Thread-unsafe Organization.Groups access from ThreadPool (3/3 reviewers)

ImportCliSessionsAsync runs on a ThreadPool thread (via Task.Run in SessionSidebar.razor), but reads Organization.Groups (a plain List<T>) without holding _organizationLock. The class invariant at CopilotService.cs:488 states: "ALL reads and writes to these lists MUST hold _organizationLock when accessed from background threads."

Additionally, CreateGroup() and OnStateChanged?.Invoke() (line 1752) fire from the ThreadPool — Blazor components subscribing to OnStateChanged expect UI-thread context.

Scenario: User clicks "Import All CLI Sessions" while the UI thread is iterating Organization.Groups (e.g., rendering the sidebar). → InvalidOperationException: Collection was modified or corrupt list state.

Fix: Use SnapshotGroups() for the read. Marshal CreateGroup and OnStateChanged calls to the UI thread via InvokeOnUI().

if (string.IsNullOrEmpty(state.Info.SessionId)) return;

// Check if history is already loaded (more than just the placeholder)
if (state.Info.History.Count > 1) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR — Empty history causes repeated disk I/O on every click (2/3 reviewers after follow-up)

If LoadBestHistoryAsync returns an empty list (unparseable events.jsonl), History.Clear() leaves Count = 0. The > 1 guard won't prevent re-loading on the next SwitchSession call, causing repeated disk parsing on every click of this session.

Fix: Set a state.HistoryLoaded = true flag after the first load attempt, checked before History.Count:

if (state.HistoryLoaded || state.Info.History.Count > 1) return;
state.HistoryLoaded = true;

Comment on lines +1693 to +1700
for (int i = 2; i <= 9999; i++)
{
var candidate = $"{baseName} ({i})";
if (!usedNames.Contains(candidate))
{
displayName = candidate;
break;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR — Dedup loop exhaustion silently overwrites existing session (2/3 reviewers)

If all 9998 candidates (baseName (2) through baseName (9999)) are taken, the loop exits without updating displayName. It stays equal to baseName, which is already in usedNames. Then _sessions[displayName] = state silently replaces the existing session in memory — data loss.

Unlikely with typical usage but theoretically reachable with 10K+ sessions sharing a cwd basename.

Fix: After the loop, check if a unique name was found:

if (usedNames.Contains(displayName))
    continue; // skip rather than overwrite

Comment on lines +434 to +436
var (importHistory, importFromDb) = await LoadBestHistoryAsync(sessionId);
state.Info.History.Clear();
foreach (var msg in importHistory)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — History mutation off-UI-thread (3/3 reviewers after follow-up)

EnsureSessionConnectedAsync runs from Task.Run (via SendPromptAsync), but this block clears and repopulates state.Info.History — a List<ChatMessage> — directly on the background thread. The parallel method LoadImportedSessionHistoryAsync (line 1798) correctly wraps the identical operation in InvokeOnUI().

Scenario: User opens an imported session and sends a message. The Blazor renderer is enumerating History for display while this code clears/repopulates it. → InvalidOperationException: Collection was modified during enumeration.

Fix: Wrap lines 434–443 in InvokeOnUI(() => { ... }), matching the pattern in LoadImportedSessionHistoryAsync.

Comment on lines +4968 to +4970
if (_sessions.TryGetValue(name, out var state) && state.IsImported && state.Info.History.Count <= 1)
{
_ = LoadImportedSessionHistoryAsync(name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Race condition: dual history loading paths (2/3 reviewers)

SwitchSession fire-and-forgets LoadImportedSessionHistoryAsync here. If the user immediately sends a message, EnsureSessionConnectedAsync also checks state.IsImported && History.Count <= 1 (line 431) and may concurrently load history. Both paths can read History.Count <= 1 before either completes, causing:

  • Duplicate LoadBestHistoryAsync calls (double disk I/O)
  • Duplicate _chatDb.BulkInsertAsync (from the EnsureSessionConnectedAsync path)
  • History cleared/repopulated twice concurrently

Fix: Use a per-session flag (e.g., IsHistoryLoading) with Interlocked.CompareExchange to ensure only one load executes. Or remove the SwitchSession eager load and rely solely on EnsureSessionConnectedAsync.

🟢 MINOR — Fire-and-forget swallows exceptions (2/3 reviewers)

Unlike the bridge call above (line 4963) which has .ContinueWith(OnlyOnFaulted), this discards the task entirely. Disk errors or corrupt events.jsonl will be silently swallowed with no user feedback or diagnostic logging.

Fix: Add .ContinueWith(t => Debug($"[IMPORT] History load failed: {t.Exception?.InnerException?.Message}"), TaskContinuationOptions.OnlyOnFaulted).

// Check if history is already loaded (more than just the placeholder)
if (state.Info.History.Count > 1) return;

var (history, _) = await LoadBestHistoryAsync(state.Info.SessionId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Missing BulkInsertAsync — history never cached in DB (3/3 reviewers after follow-up)

The fromDb flag is discarded (var (history, _)), so history loaded via SwitchSessionLoadImportedSessionHistoryAsync is never inserted into _chatDb. In contrast, EnsureSessionConnectedAsync (line 442) correctly calls _chatDb.BulkInsertAsync when !importFromDb.

Scenario: User clicks an imported session; history loads from events.jsonl. If the SwitchSession path wins the race, the DB is never populated and subsequent operations that read from the DB cache will miss this history.

Fix:

var (history, fromDb) = await LoadBestHistoryAsync(state.Info.SessionId);
// ... (inside InvokeOnUI, after populating History) ...
if (history.Count > 0 && !fromDb)
    await _chatDb.BulkInsertAsync(state.Info.SessionId, history);

@PureWeen PureWeen closed this May 1, 2026
@PureWeen PureWeen deleted the feature/import-cli-sessions branch May 1, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant