Skip to content

CopilotSdkChatClient prompts accumulate all prior user messages (missing terminal FinishReason drops assistant replies) #1102

Closed

Description

@JoshuaRowePhantom

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 completes

Because 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_id anywhere in the repo. The bug is specific to the CopilotSdkChatClient path — both gpt-5-mini and Claude copilot manifests use CopilotSdkChatClient (via AgentFactory.cs ~737/791/831/879).
  • The github-models manifest is immune. It uses openAiClient.GetChatClient(modelId).AsIChatClient() (AgentFactory.cs ~655/690), whose adapter emits FinishReason = Stop, so the final message IS persisted → interleaved history → no accumulation.
  • Not an input-composition bug. CopilotSdkChatClient is used as-is (ISelfInvokingToolChatClient, AgentChat.cs ~1967-1968); the input queue is correctly cleared each turn (AgentChat.cs ~1539 chatMessagesToSubmit.Clear(), snapshot via .ToArray() ~1593).
  • maxOutputTokens: 2048 is a red herring for the accumulation, but it aggravates the data-loss: a length-capped turn also would not be flushed, since FinishReason is 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.

  1. 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)
  2. 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

  1. added
    bugSomething isn't working
    diagnosedRoot cause identified
    queuedIn the active work queue (tracked in work-queue.md)
    on Jul 17, 2026
  2. added a commit that references this issue on Jul 21, 2026
  3. JoshuaRowePhantom commented on Jul 21, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Resolved

    This umbrella issue is resolved by the landing of its two children:

    Both were fast-forwarded into features at 98e1a7e.

  4. removed
    queuedIn the active work queue (tracked in work-queue.md)
    on Jul 21, 2026
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 workingdiagnosedRoot cause identified

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions