Repository navigation
CopilotSdkChatClient prompts accumulate all prior user messages (missing terminal FinishReason drops assistant replies) #1102
Description
Summary
An AgentChat session using the github-copilot provider (manifest "Copilot Gpt-5-mini": model gpt-5-mini, apiType: OpenAI, temperature 0.2, maxOutputTokens 2048) produced a conversation in which each USER turn's prompt accumulated ALL prior user messages, concatenated with "\n\n---\n\n". Because earlier questions stayed pinned atop every later prompt, the model kept re-answering stale questions — e.g. it repeatedly replied "I'm powered by GPT-5 mini" because "What model are you?" remained at the top of every subsequent prompt.
The accumulation is most visible on plain identity / no-tool turns, where the history degenerates into a run of consecutive USER messages and the model re-answers every earlier question on every turn.
Impact:
- Visible: on no-tool turns the prompt grows without bound and the model re-answers stale questions.
- Latent data loss: the final assistant message of every Copilot turn is currently never persisted (see Defect A). Resumed/restored chats silently lose the last assistant message of each Copilot turn even when the symptom above is masked (tool-using turns).
This is caused by two coupled defects.
Root Cause
Defect A (the trigger) — GitHub Copilot streaming path never emits a terminal FinishReason
CopilotSdkStreamAdapter.TranslateCopilotSdkSessionEvents (Phantom.Workspaces.Llm.Core\CopilotSdkStreamAdapter.cs, lines 70–188) yields ChatResponseUpdates for deltas / tools / usage and simply yield breaks on SessionIdleEvent (lines 184–185). No update ever sets FinishReason:
// CopilotSdkStreamAdapter.cs (~184-185)
case SessionIdleEvent:
yield break;StreamingPersistenceMiddleware.GetStreamingResponseAsync (Phantom.Workspaces.Llm.Core\StreamingPersistenceMiddleware.cs, lines 48–78) decides how many messages are "stable" (safe to persist) from FinishReason, and has no post-loop flush:
// StreamingPersistenceMiddleware.cs (~64-77)
var response = buffer.ToChatResponse();
var stableCount = update.FinishReason is not null
? response.Messages.Count
: Math.Max(0, response.Messages.Count - 1);
for (var i = persistedCount; i < stableCount; i++)
{
await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false);
}
persistedCount = stableCount;
yield return update;
// <-- no flush after the loop completesBecause FinishReason is always null, the last message of every Copilot turn is never persisted. On a plain no-tool turn the reply is a SINGLE assistant message (Count == 1 → stableCount == 0), so the assistant answer is never persisted. History then holds the user turn but not the reply, producing a run of consecutive user messages. (On tool-using turns a trailing Tool message breaks the user-run and masks the accumulation symptom — but the final assistant text is still lost, a latent data-loss bug for resumed/restored chats.)
Defect B (the amplifier) — BuildMessageOptions sends an unbounded trailing user-run
CopilotSdkChatClient.BuildMessageOptions (Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs, lines 852–897) walks the message list backwards collecting ALL consecutive trailing ChatRole.User messages (stops at first non-user, lines 859–868) and joins their text with "\n\n---\n\n" (line 882):
// CopilotSdkChatClient.cs (~859-882)
for (var index = materialized.Count - 1; index >= 0; index--)
{
var message = materialized[index];
if (message.Role != ChatRole.User)
{
break;
}
batchMessages.Add(message);
}
batchMessages.Reverse();
// ...
var combinedText = string.Join("\n\n---\n\n", texts);This is intentional because the Copilot CLI session is stateful and retains context server-side (design doc docs\design\github-copilot-provider-support.md, lines ~20–25), so only the NEW user turn should be sent. Correctness depends on history ending in a non-user message. Given the user-only run created by Defect A, this concatenates the entire history of user turns into one giant prompt.
Relationship between A and B
Fixing A alone RESOLVES the observed accumulation symptom: interleaved history (assistant replies persisted between user turns) makes B's trailing-user-run collapse to just the current turn. B is defense-in-depth hardening: it prevents any future user-only run (errored / cancelled / restored turn) from re-triggering the concatenation. A and B are INDEPENDENT (no ordering dependency); either can be implemented first. Recommend implementing BOTH.
Affected Files
| File | Contribution |
|---|---|
Phantom.Workspaces.Llm.Core\CopilotSdkStreamAdapter.cs |
Defect A: yield breaks on SessionIdleEvent without ever emitting a terminal FinishReason (~184-185). |
Phantom.Workspaces.Llm.Core\StreamingPersistenceMiddleware.cs |
Defect A: stableCount derived from FinishReason; no post-loop flush, so the last message is never persisted (~64-77). |
Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs |
Defect B: BuildMessageOptions backward-walks trailing user messages and joins with "\n\n---\n\n" (~852-897). |
docs\design\github-copilot-provider-support.md |
Documents the stateful-session assumption (~20-25) that makes B correct only when history ends non-user. |
Phantom.Workspaces.Llm.Core\AgentFactory.cs |
Both copilot manifests build CopilotSdkChatClient (~737/791/831/879); github-models path uses openAiClient.GetChatClient(modelId).AsIChatClient() (~655/690). |
Phantom.Workspaces.Llm.Core\AgentChat.cs |
CopilotSdkChatClient used as ISelfInvokingToolChatClient (~1967-1968); input queue cleared each turn (~1539) and snapshotted via .ToArray() (~1593). |
Scope & Immunity Notes
- Turn-shape-specific, not GPT-5/Responses-specific. There is no
previous_response_idanywhere in the repo. The bug is specific to theCopilotSdkChatClientpath — bothgpt-5-miniand Claude copilot manifests useCopilotSdkChatClient(viaAgentFactory.cs~737/791/831/879). - The github-models manifest is immune. It uses
openAiClient.GetChatClient(modelId).AsIChatClient()(AgentFactory.cs~655/690), whose adapter emitsFinishReason = Stop, so the final message IS persisted → interleaved history → no accumulation. - Not an input-composition bug.
CopilotSdkChatClientis used as-is (ISelfInvokingToolChatClient,AgentChat.cs~1967-1968); the input queue is correctly cleared each turn (AgentChat.cs~1539chatMessagesToSubmit.Clear(), snapshot via.ToArray()~1593). maxOutputTokens: 2048is a red herring for the accumulation, but it aggravates the data-loss: a length-capped turn also would not be flushed, sinceFinishReasonis the only finality signal.
Note: This bug has been split into sub-items. The design above is the full original design; each sub-item contains the relevant portion.
Implementation Sub-Items
Both sub-items are independent (no ordering dependency) and may be done in parallel. Implementing both is recommended.
- CopilotSdkStreamAdapter never emits terminal FinishReason, so the last message of every Copilot turn is never persisted #1103 — CopilotSdkStreamAdapter never emits terminal FinishReason, so the last message of every Copilot turn is never persisted (no dependencies)
- CopilotSdkChatClient.BuildMessageOptions sends an unbounded trailing user-run from history instead of only the current turn's batch #1104 — CopilotSdkChatClient.BuildMessageOptions sends an unbounded trailing user-run from history instead of only the current turn's batch (no dependencies)
Expected Tests
End-to-end: a multi-turn no-tool Copilot conversation must not accumulate prior user text into later prompts.
| Test Name | Class | What It Verifies |
|---|---|---|
CopilotConversation_MultiTurnNoToolTurns_DoesNotAccumulatePriorUserText |
StreamingPersistenceMiddlewareTests (or a new integration test class) |
Across several no-tool turns, each turn's outbound prompt contains only the current user message — earlier questions are not re-sent. |
CopilotTurn_NoToolReply_FinalAssistantMessageIsPersisted |
StreamingPersistenceMiddlewareTests |
The single assistant message of a no-tool Copilot turn is persisted (guards the latent data-loss). |
See the two sub-items for the component-level adapter, middleware, and BuildMessageOptions tests.
Activity
Fix #1103: emit terminal FinishReason on Copilot SessionIdleEvent
JoshuaRowePhantom commentedon Jul 21, 2026
Resolved
This umbrella issue is resolved by the landing of its two children:
SessionIdleEventBuildMessageOptionsmerge to current turn's batchBoth were fast-forwarded into
featuresat 98e1a7e.