Skip to content

GitHub Copilot chats don't restore history into the Copilot session #3

Closed

Description

@JoshuaRowePhantom

Summary

When a github-copilot agent chat is restored (e.g. after restarting Phantom.Workspaces), the CopilotSdkChatClient creates a fresh CopilotSession. The Copilot CLI session starts with no conversation history, so the model has no awareness of messages exchanged before the current AgentChat lifetime.

Expected behavior

On restore, prior conversation turns are visible to the model, as if the session had never been interrupted.

Actual behavior

The Copilot CLI session is always created fresh via CreateSessionAsync. Only messages sent after session creation are visible to the model. Earlier turns stored in the workspace entity history are never forwarded to the SDK.

Root cause

CopilotSdkChatClient is stateful: it retains a single CopilotSession per client lifetime and forwards only the latest user message per turn (ExtractPrompt). The agent framework records history for presentation and persistence, but CopilotSdkChatClient deliberately does not replay it into the session.

The messages parameter that the framework passes to GetResponseAsync / GetStreamingResponseAsync DOES contain the full conversation history -- AgentPersistenceChatHistoryProvider.ProvideChatHistoryAsync prepends the stored history before the call reaches the chat client -- but ExtractPrompt discards everything except the last user message.

SDK documentation

The Copilot SDK has a first-class solution for this:

CopilotClient.ResumeSessionAsync(sessionId, ResumeSessionConfig, cancellationToken)

"This allows you to continue a previous conversation, maintaining all conversation history."

Session data is persisted to disk by the CLI process and survives process restarts:

"Note: session data on disk is preserved, so sessions can be resumed later."

CopilotSession.SessionId is the stable identifier used to resume. ResumeSessionConfig accepts the same fields as SessionConfig (Tools, Model, SystemMessage, OnPermissionRequest, etc.) so the resumed session can be reconfigured (e.g. with a different tool set).

Proposed fix

Store and restore the Copilot SDK session ID

  1. Expose the SDK session ID from CopilotSdkChatClient.
    After CreateSessionAsync succeeds, this.copilotSession.SessionId is available.
    Add public string? CopilotSdkSessionId => this.copilotSession?.SessionId; (or expose it via an event/callback fired from EnsureSessionAsync).

  2. Persist the SDK session ID alongside the agent chat state.
    After the first session is established, save the SDK session ID where it survives process restart. The agent-session entity already holds runtime fields like cwd; add an optional copilot-sdk-session-id string field (or embed it in the serialized AgentSession JSON blob). AgentChat saves this when the session ID is first known.

  3. Pass the stored session ID to CopilotSdkChatClient on restore.
    AgentFactory.CreateGitHubCopilotClient (or the CopilotSdkChatClient constructor) accepts an optional string? copilotSdkSessionId. When non-null, EnsureSessionAsync calls client.ResumeSessionAsync(copilotSdkSessionId, BuildResumeSessionConfig(...)) instead of client.CreateSessionAsync(BuildSessionConfig(...)).

  4. Add BuildResumeSessionConfig (parallel to BuildSessionConfig).
    Maps the same modelId, byokOptions, ChatOptions inputs to a ResumeSessionConfig (same fields: Model, Provider, Tools, SystemMessage, ReasoningEffort, OnPermissionRequest).

Session signature

When resuming, the session is already live with the existing history; the signature logic continues to apply normally for detecting when a new session is needed (tool set change, instructions change, etc.). A resumed session that goes stale falls back to CreateSessionAsync as today.

Changes required

  • CopilotSdkChatClient:

    • Add string? copilotSdkSessionId constructor parameter.
    • In EnsureSessionAsync, call ResumeSessionAsync instead of CreateSessionAsync when copilotSdkSessionId is set and no session exists yet. Clear copilotSdkSessionId after the first call (whether resume succeeds or falls back to create) so subsequent session recreations always use CreateSessionAsync.
    • Add static BuildResumeSessionConfig (mirrors BuildSessionConfig).
    • Expose the live session ID so callers can persist it.
  • AgentFactory.CreateGitHubCopilotClient:

    • Accept the stored SDK session ID and forward it to CopilotSdkChatClient.
  • agent-session entity schema:

    • Add optional copilot-sdk-session-id string field.
  • AgentChat:

    • After the Copilot session is established, write the SDK session ID to the agent-session entity if it has changed.
    • On creation, read the stored SDK session ID from the agent-session entity and pass it through AgentFactory.

Activity

  1. JoshuaRowePhantom commented on Jun 24, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Investigation Findings

    Root Cause

    File: Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs — EnsureSessionAsync, line 474

    // Always creates a brand-new session — ResumeSessionAsync is never called anywhere:
    var session = await this.copilotClient.CreateSessionAsync(sessionConfig, cancellationToken);

    The full cause chain:

    Step Location What happens
    1 AgentChat.InitializeAsync AgentFactory.CreateChatClient called with no hint of a prior Copilot CLI session ID
    2 AgentFactory.CreateGitHubCopilotClient new CopilotSdkChatClient(...) — no resumeCopilotSessionId parameter exists
    3 CopilotSdkChatClient.EnsureSessionAsync Always calls CreateSessionAsync — fresh session, zero history
    4 AgentPersistenceChatHistoryProvider Provides persisted messages to the Phantom UI pipeline, but never to the Copilot CLI
    5 CopilotSdkChatClient.ExtractPrompt Intentionally sends only the last user message — correct within a session lifetime, data-loss on restore

    The Copilot SDK's ResumeSessionAsync(sessionId, config) exists for exactly this purpose but UserMessageAttachmentBlob is never instantiated. The missing piece is that the Copilot CLI session ID is never stored in PersistedAgent and never passed back to CopilotSdkChatClient on restore.


    Missing Tests

    Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientTests.cs:

    1. Construction_AcceptsResumeCopilotSessionId_AndExposesNullActiveSessionId — construct with resumeCopilotSessionId: "cli-abc"; assert no session started yet and ActiveCopilotSessionId == null
    2. BuildResumeSessionConfig_ForwardsFunctionToolsInstructionsAndModel — mirrors existing BuildSessionConfig test
    3. BuildResumeSessionConfig_ForwardsReasoningEffort
    4. BuildResumeSessionConfig_ForwardsByokProvider

    AgentPersistenceStoreContractTests.cs (abstract base):

    1. StoreAsync_WithCopilotCliSessionId_ThenRestoreAsync_ReturnsCopilotCliSessionId
    2. StoreAsync_WithNullCopilotCliSessionId_ThenRestoreAsync_ReturnsNullCopilotCliSessionId — backward-compatibility test for old records
    3. StoreAsync_OverwritesCopilotCliSessionId_WhenUpdated

    AgentPersistenceChatHistoryProviderTests.cs:

    1. SetCopilotSessionIdProvider_IncludesIdInSubsequentStoreCall — provider returns "copilot-abc"; assert StoreAsync receives PersistedAgent with that CopilotCliSessionId
    2. SetCopilotSessionIdProvider_WhenProviderReturnsNull_OmitsId

    Recommended Fix (6 changes)

    Change 1 — Add CopilotCliSessionId to PersistedAgent (IAgentPersistenceStore.cs)

    public readonly record struct PersistedAgent
    {
        public required string AgentSessionId { get; init; }
        public BsonDocument? AgentSessionJson { get; init; }
        public BsonDocument? AgentDefinitionJson { get; init; }
        public string? CopilotCliSessionId { get; init; }  // NEW
    }

    Change 2 — CopilotSdkChatClient.cs

    • Add string? resumeCopilotSessionId constructor parameter and bool sessionEverCreated field
    • Add public string? ActiveCopilotSessionId => this.copilotSession?.SessionId;
    • Add static BuildResumeSessionConfig(modelId, byokOptions, options) (mirrors BuildSessionConfig, returns ResumeSessionConfig)
    • In EnsureSessionAsync, on first creation call ResumeSessionAsync when resumeCopilotSessionId is set, with fallback to CreateSessionAsync on InvalidOperationException:
    CopilotSession session;
    if (!this.sessionEverCreated && this.resumeCopilotSessionId is not null)
    {
        try
        {
            session = await this.copilotClient.ResumeSessionAsync(
                this.resumeCopilotSessionId,
                BuildResumeSessionConfig(this.modelId, this.byokOptions, options),
                cancellationToken).ConfigureAwait(false);
        }
        catch (InvalidOperationException)
        {
            // CLI session no longer on disk; fall back to fresh session.
            session = await this.copilotClient.CreateSessionAsync(
                BuildSessionConfig(this.modelId, this.byokOptions, options),
                cancellationToken).ConfigureAwait(false);
        }
    }
    else
    {
        session = await this.copilotClient.CreateSessionAsync(
            BuildSessionConfig(this.modelId, this.byokOptions, options),
            cancellationToken).ConfigureAwait(false);
    }
    this.sessionEverCreated = true;

    Change 3 — AgentPersistenceChatHistoryProvider.cs

    Add Func<string?>? copilotSessionIdProvider field and SetCopilotSessionIdProvider(Func<string?> provider) method. Include CopilotCliSessionId = this.copilotSessionIdProvider?.Invoke() in the PersistedAgent initializer in both StoreAsync call sites.

    Change 4 — AgentFactory.cs

    Thread string? resumeCopilotSessionId = null through CreateChatClient → CreateGitHubCopilotResult → CreateGitHubCopilotClient → new CopilotSdkChatClient(...).

    Change 5 — AgentChat.cs

    // Read persisted CLI session ID on restore:
    var restoredCopilotCliSessionId = restoredAgent.HasValue ? restoredAgent.Value.CopilotCliSessionId : null;
    
    // Pass it to factory:
    AgentFactory.CreateChatClient(..., resumeCopilotSessionId: restoredCopilotCliSessionId);
    
    // Register session-ID provider after client is resolved:
    if (resolvedClient is CopilotSdkChatClient copilotSdkClient)
    {
        this.persistenceProvider.SetCopilotSessionIdProvider(() => copilotSdkClient.ActiveCopilotSessionId);
    }

    Change 6 — Persistence store implementations

    • InMemoryAgentPersistenceStore.cs: add CopilotCliSessionId = request.Agent.CopilotCliSessionId ?? session.Agent?.CopilotCliSessionId to the merge with expression
    • MongoDbAgentPersistenceStore.cs: add string? CopilotCliSessionId to MongoDbPersistedSessionDocument, store it in StoreAsync, restore it in RestoreAsync

    Edge Cases

    • CLI session no longer on disk (user cleared state, different machine): ResumeSessionAsync throws InvalidOperationException — fall back to CreateSessionAsync (handled above)
    • MongoDB backward compatibility: old records without CopilotCliSessionId deserialize as null → CreateSessionAsync path → identical to current behavior; no migration needed
    • ActiveCopilotSessionId is null on the very first turn (before EnsureSessionAsync runs): stored as null; set correctly from the second turn onward
    • Tool-set changes force a new CLI session: !sessionEverCreated ensures ResumeSessionAsync is only attempted on the very first creation; subsequent recreations always use CreateSessionAsync
  2. JoshuaRowePhantom commented on Jun 25, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Fixed in 095d79d (tag fix-copilot-session-resume): the Copilot SDK session id is now persisted on PersistedAgent.CopilotSdkSessionId and resumed via ResumeSessionAsync on restore, so prior conversation history remains visible to the model. Falls back to creating a fresh session if the on-disk session can no longer be resumed. Unit + store-contract tests added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingverifiedIssue has been verified

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions