Repository navigation
GitHub Copilot chats don't restore history into the Copilot session #3
Description
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
-
Expose the SDK session ID from
CopilotSdkChatClient.
AfterCreateSessionAsyncsucceeds,this.copilotSession.SessionIdis available.
Addpublic string? CopilotSdkSessionId => this.copilotSession?.SessionId;(or expose it via an event/callback fired fromEnsureSessionAsync). -
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. Theagent-sessionentity already holds runtime fields likecwd; add an optionalcopilot-sdk-session-idstring field (or embed it in the serializedAgentSessionJSON blob).AgentChatsaves this when the session ID is first known. -
Pass the stored session ID to
CopilotSdkChatClienton restore.
AgentFactory.CreateGitHubCopilotClient(or theCopilotSdkChatClientconstructor) accepts an optionalstring? copilotSdkSessionId. When non-null,EnsureSessionAsynccallsclient.ResumeSessionAsync(copilotSdkSessionId, BuildResumeSessionConfig(...))instead ofclient.CreateSessionAsync(BuildSessionConfig(...)). -
Add
BuildResumeSessionConfig(parallel toBuildSessionConfig).
Maps the samemodelId,byokOptions,ChatOptionsinputs to aResumeSessionConfig(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? copilotSdkSessionIdconstructor parameter. - In
EnsureSessionAsync, callResumeSessionAsyncinstead ofCreateSessionAsyncwhencopilotSdkSessionIdis set and no session exists yet. ClearcopilotSdkSessionIdafter the first call (whether resume succeeds or falls back to create) so subsequent session recreations always useCreateSessionAsync. - Add static
BuildResumeSessionConfig(mirrorsBuildSessionConfig). - Expose the live session ID so callers can persist it.
- Add
-
AgentFactory.CreateGitHubCopilotClient:- Accept the stored SDK session ID and forward it to
CopilotSdkChatClient.
- Accept the stored SDK session ID and forward it to
-
agent-sessionentity schema:- Add optional
copilot-sdk-session-idstring field.
- Add optional
-
AgentChat:- After the Copilot session is established, write the SDK session ID to the
agent-sessionentity if it has changed. - On creation, read the stored SDK session ID from the
agent-sessionentity and pass it throughAgentFactory.
- After the Copilot session is established, write the SDK session ID to the
Activity
JoshuaRowePhantom commentedon Jun 24, 2026
Investigation Findings
Root Cause
File:
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs—EnsureSessionAsync, line 474The full cause chain:
AgentChat.InitializeAsyncAgentFactory.CreateChatClientcalled with no hint of a prior Copilot CLI session IDAgentFactory.CreateGitHubCopilotClientnew CopilotSdkChatClient(...)— noresumeCopilotSessionIdparameter existsCopilotSdkChatClient.EnsureSessionAsyncCreateSessionAsync— fresh session, zero historyAgentPersistenceChatHistoryProviderCopilotSdkChatClient.ExtractPromptThe Copilot SDK's
ResumeSessionAsync(sessionId, config)exists for exactly this purpose butUserMessageAttachmentBlobis never instantiated. The missing piece is that the Copilot CLI session ID is never stored inPersistedAgentand never passed back toCopilotSdkChatClienton restore.Missing Tests
Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientTests.cs:Construction_AcceptsResumeCopilotSessionId_AndExposesNullActiveSessionId— construct withresumeCopilotSessionId: "cli-abc"; assert no session started yet andActiveCopilotSessionId == nullBuildResumeSessionConfig_ForwardsFunctionToolsInstructionsAndModel— mirrors existingBuildSessionConfigtestBuildResumeSessionConfig_ForwardsReasoningEffortBuildResumeSessionConfig_ForwardsByokProviderAgentPersistenceStoreContractTests.cs(abstract base):StoreAsync_WithCopilotCliSessionId_ThenRestoreAsync_ReturnsCopilotCliSessionIdStoreAsync_WithNullCopilotCliSessionId_ThenRestoreAsync_ReturnsNullCopilotCliSessionId— backward-compatibility test for old recordsStoreAsync_OverwritesCopilotCliSessionId_WhenUpdatedAgentPersistenceChatHistoryProviderTests.cs:SetCopilotSessionIdProvider_IncludesIdInSubsequentStoreCall— provider returns"copilot-abc"; assertStoreAsyncreceivesPersistedAgentwith thatCopilotCliSessionIdSetCopilotSessionIdProvider_WhenProviderReturnsNull_OmitsIdRecommended Fix (6 changes)
Change 1 — Add
CopilotCliSessionIdtoPersistedAgent(IAgentPersistenceStore.cs)Change 2 —
CopilotSdkChatClient.csstring? resumeCopilotSessionIdconstructor parameter andbool sessionEverCreatedfieldpublic string? ActiveCopilotSessionId => this.copilotSession?.SessionId;static BuildResumeSessionConfig(modelId, byokOptions, options)(mirrorsBuildSessionConfig, returnsResumeSessionConfig)EnsureSessionAsync, on first creation callResumeSessionAsyncwhenresumeCopilotSessionIdis set, with fallback toCreateSessionAsynconInvalidOperationException:Change 3 —
AgentPersistenceChatHistoryProvider.csAdd
Func<string?>? copilotSessionIdProviderfield andSetCopilotSessionIdProvider(Func<string?> provider)method. IncludeCopilotCliSessionId = this.copilotSessionIdProvider?.Invoke()in thePersistedAgentinitializer in bothStoreAsynccall sites.Change 4 —
AgentFactory.csThread
string? resumeCopilotSessionId = nullthroughCreateChatClient→CreateGitHubCopilotResult→CreateGitHubCopilotClient→new CopilotSdkChatClient(...).Change 5 —
AgentChat.csChange 6 — Persistence store implementations
InMemoryAgentPersistenceStore.cs: addCopilotCliSessionId = request.Agent.CopilotCliSessionId ?? session.Agent?.CopilotCliSessionIdto the mergewithexpressionMongoDbAgentPersistenceStore.cs: addstring? CopilotCliSessionIdtoMongoDbPersistedSessionDocument, store it inStoreAsync, restore it inRestoreAsyncEdge Cases
ResumeSessionAsyncthrowsInvalidOperationException— fall back toCreateSessionAsync(handled above)CopilotCliSessionIddeserialize asnull→CreateSessionAsyncpath → identical to current behavior; no migration neededActiveCopilotSessionIdis null on the very first turn (beforeEnsureSessionAsyncruns): stored asnull; set correctly from the second turn onward!sessionEverCreatedensuresResumeSessionAsyncis only attempted on the very first creation; subsequent recreations always useCreateSessionAsyncJoshuaRowePhantom commentedon Jun 25, 2026
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.
Resume the Copilot SDK session on restore so history is preserved (#3)