Skip to content

Stray assistant word (e.g. 'court') leaks into parent transcript after sub-agent completion #1197

Description

@JoshuaRowePhantom

Stray assistant word (deterministically court) is persisted by the GitHub Copilot CLI on tool-only turns — Phantom.Workspaces can only DETECT and MASK it

Summary

In the parent/main agent chat that orchestrates background sub-agents, a stray, out-of-context word — reproducibly and deterministically the literal court — appears as a lone assistant turn immediately before a tool call on turns where the assistant produces no visible preamble (tool-only turns). Most visibly it appears before read_agent after a SubagentCompleted system notification, but the same literal court has been observed before other tool calls (e.g. read_agent, grep, powershell, read_powershell, report_intent).

Confirmed root cause (unchanged from prior investigation): the GitHub Copilot CLI's own aggregation/persistence layer substitutes the literal string "court" for the empty string as the visible-text data.content of an assistant.message event on tool-only turns. The evidence is on disk in ~/.copilot/session-state/<sessionId>/events.jsonl — dozens/hundreds of identical "content":"court" records coexist with thousands of correctly-persisted "content":"" records; reloading the CLI redisplays them because the CLI rehydrates from that file. This is not a Phantom.Workspaces routing bug and not a GitHub.Copilot.SDK bug — the literal is written by the CLI binary before either surface sees it.

Phantom.Workspaces cannot root-cause fix this (we don't own the CLI's persistence layer). What we CAN do — and what this issue now specifies — is (a) detect the pathological turn shape on both the streaming/write path and the rehydration/read path and emit telemetry, and (b) mask the stray literal so it never renders. Both are workarounds; the true fix must land upstream in the GitHub Copilot CLI.

Reproduction

Observed pattern (@JoshuaRowePhantom, three verbatim occurrences in one conversation; and again against read_agent in this current session):

[system]     Agent "fix-1193" (general-purpose) has completed successfully.
             Use read_agent with agent_id "fix-1193" to retrieve the full results.
[assistant]  court
             ▶ tool read_agent(agent_id: "fix-1193")
[assistant]  Fix landed. Dispatching verify-1193.
             $ tools (report_intent, task) (2 calls)

Invariants of every court occurrence:

  1. The assistant turn's entire visible text is the single token court (whole-string, case-sensitive, after trim — not a substring).
  2. That same turn contains one or more tool calls (FunctionCallContent).
  3. The turn otherwise carries no legitimate preamble text.
  4. The literal is deterministically court — never any other word.
  5. court survives reload because it is written into events.jsonl.

Persistence & Determinism evidence (unchanged)

The Copilot CLI persists each session under ~/.copilot/session-state/<sessionId>/events.jsonl. Representative event:

{"type":"assistant.message",
 "data":{
   "messageId":"",
   "model":"claude-opus-4.8",
   "content":"court",                       ← the stray literal, PERSISTED
   "toolRequests":[{
     "toolCallId":"toolu_01JWZAXwyYmR2sVbKrr8JYDj",
     "name":"read_agent",
     "arguments":{"agent_id":"fix-ui","wait":true},
     "type":"function"
   }, ],
   "outputTokens":124,
   "requestId":"","apiCallId":""
 }, "id":"","timestamp":"","parentId":""}

Rate / determinism evidence (single owner's session store):

Session events.jsonl size assistant.message events content == "court"
1b397e43-… ~477 MB 16,923 96
0d796cb5-… ~232 MB (many) 266
2388a014-… ~516 MB (many) 1
c115e131-… ~54 MB (many) 1

Breakdown of the tool called on the court turns (representative session):

Tool called on the same turn Count Share
read_agent 141 ~96 %
grep 2
read_powershell 2
powershell 1
report_intent 1

So the stray court is not exclusive to read_agent; it appears on any tool-only turn whose visible-text content should have been empty. SubagentCompleted → read_agent is just the most common instance because that boundary produces the most preamble-less tool calls. In the same file, 11,041 tool-only turns are persisted correctly with content:"", versus 96 poisoned with content:"court" — a rare stochastic replacement, but always the same literal.

outputTokens:124 on a content of length 5 shows the model generated ≥120 tokens of reasoning / tool arguments; only "court" landed in the visible-text channel. The CLI redisplays the persisted court on reload (it does not regenerate it), so prevention must happen at write-time upstream, or Phantom.Workspaces must mask on both write and read.

We verified the literal court does not exist in the Phantom.Workspaces features/ tree (whole-word, case-insensitive, across all .cs, .xaml, .json, .yaml, .md, .resx, .txt, prompts, tests, submodules — zero hits), nor in the GitHub.Copilot.SDK / Microsoft.Extensions.AI / OpenAI / Microsoft.ML.Tokenizers NuGet assets (419 files, zero hits). The determinism therefore lives inside the compiled Copilot CLI itself.

Where the literal enters Phantom.Workspaces

Once the CLI emits an assistant.message with data.content == "court" (either during streaming, or replayed from events.jsonl on session resume), it enters our code at the following seams. These are the sites where detection and masking must be applied:

Seam File / lines What happens today
Delta translation (streaming write) features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs:88-94AssistantMessageDeltaEventnew TextContent(delta.Data.DeltaContent) The stream adapter faithfully lifts the CLI's DeltaContent into a TextContent. If the CLI's stream carried "court" for this turn, it becomes a visible TextContent("court") here. Same site tags with agentId.
Router catch-all features/Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs:475-501PushUpdate empty-AgentId branch: if (string.IsNullOrEmpty(agentId)) { this.rootWriter.TryWrite(update); return; } Root/parent-authored updates (including the poisoned TextContent("court")) flow straight to the parent transcript. No content-shape validation.
Streaming persistence (write to store) features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:101-122PersistMessageAsync(ChatMessage) calls store.StoreAsync with the finalised ChatMessage including Contents. If the finalised message is [TextContent("court"), FunctionCallContent(...)], this persists that shape into Mongo verbatim, propagating the CLI defect into our own store.
Read-path rehydration features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1298-1314LoadInitialHistory copies message.Contents straight onto the new AgentChatHistoryItem. Any persisted TextContent("court") beside a FunctionCallContent is rendered verbatim on session resume.

Detection

The exact signature — designed to have effectively zero false positives — is:

A ChatMessage (or equivalent in-flight assistant turn) whose Contents contains at least one FunctionCallContent, AND whose combined visible-text (concatenation of all non-reasoning TextContent.Text values on that turn, trimmed) equals exactly court (ordinal, case-sensitive, whole string — not a substring, not a prefix, not a token in a sentence).

Rationale for the whole-string, case-sensitive match: legitimate assistant prose can and does contain the word "court" (as in "supreme court", "courtesy"). Requiring the entire visible-text of the turn to be court, combined with the tool-only constraint (at least one FunctionCallContent present), makes matching legitimate prose essentially impossible — a real answer that consists solely of the word "court" without punctuation and simultaneously carries a tool call is not a shape our agents ever produce.

Formally, in a helper (CopilotStrayLiteralDetector.IsStrayLiteralAssistantTurn):

internal static class CopilotStrayLiteralDetector
{
    // Extend via config in future; the CLI defect is currently known to emit only "court".
    private static readonly HashSet<string> KnownStrayLiterals = new(StringComparer.Ordinal) { "court" };

    public static bool IsStrayLiteralAssistantTurn(ChatMessage message, out string literal)
    {
        literal = string.Empty;
        if (message.Role != ChatRole.Assistant) return false;

        var hasToolCall = message.Contents.OfType<FunctionCallContent>().Any();
        if (!hasToolCall) return false;

        // Combined VISIBLE text only — reasoning content is not visible-text.
        var visibleText = string.Concat(
            message.Contents.OfType<TextContent>().Select(t => t.Text ?? string.Empty))
            .Trim();

        if (KnownStrayLiterals.Contains(visibleText))
        {
            literal = visibleText;
            return true;
        }
        return false;
    }
}

Detection must fire in two places:

  1. On stream / write — at the point where the finalised assistant ChatMessage is about to be persisted. Add a guard in StreamingPersistenceMiddleware.PersistMessageAsync (StreamingPersistenceMiddleware.cs:101-122) immediately before store.StoreAsync(...):

    if (CopilotStrayLiteralDetector.IsStrayLiteralAssistantTurn(message, out var literal))
    {
        this.logger?.LogWarning(
            "Copilot CLI stray-literal defect: replacing whole-content '{Literal}' " +
            "on tool-only assistant turn with empty string. SessionId={SessionId} " +
            "MessageId={MessageId}. See #1197.",
            literal, session.Id, message.MessageId);
        CopilotStrayLiteralDetector.StripVisibleTextInPlace(message);
    }
  2. On read / rehydrate — at AgentChat.LoadInitialHistory (AgentChat.cs:1298-1314) so that already-poisoned events.jsonl sessions and any already-poisoned Mongo documents stop rendering court in the GUI:

    foreach (var message in initialMessages)
    {
        if (CopilotStrayLiteralDetector.IsStrayLiteralAssistantTurn(message, out var literal))
        {
            this.logger?.LogWarning(
                "Copilot CLI stray-literal defect on load: stripping '{Literal}' from " +
                "rehydrated tool-only assistant turn. See #1197.", literal);
            CopilotStrayLiteralDetector.StripVisibleTextInPlace(message);
        }
        this.AddHistoryItem(new AgentChatHistoryItem { ... });
    }

Both sites emit a LogWarning tagged with the session id, message id (where available), and issue #1197 so field occurrences are observable and countable (also usable to feed a counter / diagnostic in future).

Workaround / Mitigation

This is an explicit workaround for an upstream Copilot CLI defect. The CLI writes court into data.content on tool-only turns; we don't own that code. Our masking has three defence-in-depth layers:

(a) Strip on write — streaming persistence

StreamingPersistenceMiddleware.PersistMessageAsync (features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:101-122) is the last chance before the poisoned shape reaches the persistence store. Apply the detector guard shown above and rewrite the message in place:

public static void StripVisibleTextInPlace(ChatMessage message)
{
    // Remove all TextContent items on the turn (the entire visible text was the stray literal).
    for (var i = message.Contents.Count - 1; i >= 0; i--)
    {
        if (message.Contents[i] is TextContent) message.Contents.RemoveAt(i);
    }
}

After stripping, the persisted message contains only FunctionCallContent items — matching the correct shape of the 11,041 clean turns in the same session file.

(b) Sanitise on read — rehydration

AgentChat.LoadInitialHistory (features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1298-1314) is the read-side seam. Apply the same detector and stripper before constructing the AgentChatHistoryItem. This handles:

  • Sessions where the CLI already wrote court into ~/.copilot/session-state/<sid>/events.jsonl prior to our rollout — the resume path stops rendering it in the GUI even though the CLI's own file is still polluted.
  • Any Mongo documents that were persisted before layer (a) was in place.

(c) Router-level guard — defence-in-depth

CopilotSubAgentRouter.PushUpdate empty-AgentId catch-all (features/Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs:475-501) is where root-agent updates flow into the parent transcript. Add a light-touch check on the individual ChatResponseUpdate: if the update carries exactly one TextContent whose trimmed text is court and the same update (or an update seen earlier in the same Msg group) carries a FunctionCallContent on the parent, drop the TextContent. This is a belt-and-braces measure for the in-flight case where PersistMessageAsync finalisation happens after the update has already been echoed to the transcript sink. Log the same warning.

Because updates are streamed piecewise, the router-level check is heuristic (whole-content match on a single update whose only visible text is court); the authoritative match is on the finalised ChatMessage in layer (a). Layers (a) and (b) are the primary defence; (c) is optional.

Precision caveat

The detector is intentionally whole-string, case-sensitive, and gated on tool-only turns because:

  • Substring / prefix / case-insensitive matches would strip legitimate prose like "The court ruled…".
  • Requiring an accompanying FunctionCallContent prevents false positives on any pure-prose assistant turn that legitimately answers with the single word "court" (extremely rare in practice; never observed to co-occur with a tool call in the same message).
  • The set of known stray literals is a config-extensible HashSet<string> (KnownStrayLiterals). If the upstream CLI defect ever changes its literal (e.g. to another word), we extend the set without changing the detection contract.

Upstream note

The true fix is upstream in the GitHub Copilot CLI. The CLI's assistant-message aggregation/persistence path substitutes court for the empty string when finalising an assistant.message whose visible-text stream was empty and whose turn carried tool calls. Phantom.Workspaces can only detect and mask this locally. We should track this as an upstream report against the Copilot CLI (evidence: on-disk events.jsonl with content:"court" co-located with toolRequests[]; deterministic reproduction on SubagentCompleted → read_agent; not present in any inspectable asset shipped by the CLI). Filing that upstream report is out of scope for this issue's implementation but is tracked here.

Considered / Background — prior Phantom.Workspaces routing diagnosis (SUPERSEDED as root cause; retained as background)

Preserved for context. Before we inspected the persisted CLI events file, the diagnosis was located inside Phantom.Workspaces at CopilotSubAgentRouter's empty-AgentId catch-all. That path is real code and would leak untagged deltas into the parent transcript if it fired, but it is not the source of the specific literal court observed on disk — the CLI writes the literal into events.jsonl before Phantom.Workspaces sees it.

  • Prior head-of-chain hypothesis: CopilotSubAgentRouter.PushUpdate (CopilotSubAgentRouter.cs:475-501) unconditionally writes any ChatResponseUpdate whose derived agentId is null/empty into the parent's visible-text sink (rootWriter). agentId derives from the first non-empty ParentToolCallId in the update's contents; every event kind in CopilotSdkStreamAdapter inherits the SDK's event-level AgentId verbatim (CopilotSdkStreamAdapter.cs:88-112, 185-195) with no fallback to nested carriers such as Data.Kind.AgentId. Tag() (CopilotSdkStreamAdapter.cs:248-257) drops the property when agentId is empty, so GetParentToolCallId returns null and PushUpdate hits the catch-all.
  • Sub-mechanisms considered: late child delta with empty AgentId post-HandleSubAgentResultAsync (:419-467), parent reasoning delta reaching the transcript when AgentViewModel.IsReasoningVisible is on, ChildRoutingEntry.pending race with CompleteAsync teardown, injected system-notification splitting an in-flight assistant delta.
  • Why superseded as root cause: the persisted content:"court" in ~/.copilot/…/events.jsonl predates any Phantom.Workspaces routing. The router's empty-AgentId catch-all is still worth hardening as defence-in-depth (workaround layer (c) above and the router tests below), but it is not what put court on the owner's screen.

Affected Files (Phantom.Workspaces)

File Role
features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs (:88-94) Delta-to-TextContent translation. Reference point for where the poisoned stream first materialises as a Phantom.Workspaces content object.
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs (:101-122, PersistMessageAsync) Primary write-path mitigation site. Add detector + in-place strip immediately before store.StoreAsync. Add a Microsoft.Extensions.Logging.ILogger dependency on the middleware if not present.
features/Phantom.Workspaces.Llm.Core/AgentChat.cs (:1298-1314, LoadInitialHistory) Primary read-path mitigation site. Apply detector + strip before AddHistoryItem.
features/Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs (:475-501, PushUpdate empty-AgentId branch) Router-level defence-in-depth guard.
features/Phantom.Workspaces.Llm.Core/CopilotStrayLiteralDetector.cs (new) New helper containing KnownStrayLiterals, IsStrayLiteralAssistantTurn, and StripVisibleTextInPlace. Shared between write, read, and router sites.
features/Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.cs No change strictly required — read-side sanitisation in AgentChat.LoadInitialHistory covers already-poisoned Mongo documents. Optional migration remains a possibility but is deferred.

Expected Tests

New tests. Class names match existing test files in features/Phantom.Workspaces.Llm.Core.Tests/ (StreamingPersistenceMiddlewareTests, CopilotSubAgentRouterTests, AgentChatPersistenceTests, CopilotSdkStreamAdapterTests). Follow the Subject_Scenario_ExpectedOutcome naming already used there.

Test Name Class What It Verifies
PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCourt_StripsTextContentBeforeStore StreamingPersistenceMiddlewareTests When the finalised ChatMessage is [TextContent("court"), FunctionCallContent(read_agent)], the message passed to store.StoreAsync contains only the FunctionCallContent — the TextContent("court") is stripped in place.
PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCourt_LogsWarningWithSessionAndMessageId StreamingPersistenceMiddlewareTests Detection telemetry: hitting the stray-literal shape fires a single LogWarning referencing session id, message id, and issue #1197.
PersistMessageAsync_AssistantTurnContainingCourtInProse_LeavesTextContentUnchanged StreamingPersistenceMiddlewareTests Precision: [TextContent("The court adjourned"), FunctionCallContent(read_agent)] is persisted verbatim; no strip, no warning.
PersistMessageAsync_PureTextAssistantTurnWithWholeContentCourt_LeavesTextContentUnchanged StreamingPersistenceMiddlewareTests Precision: an assistant turn with [TextContent("court")] and NO FunctionCallContent is persisted verbatim (the gate on a tool call rules it out).
PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCOURT_DoesNotStrip StreamingPersistenceMiddlewareTests Precision: case-sensitivity — "COURT" (all caps) is not stripped.
LoadInitialHistory_PersistedToolOnlyAssistantTurnWithCourt_RehydratesWithoutVisibleTextContent AgentChatPersistenceTests Read-path sanitisation: a previously-persisted ChatMessage with [TextContent("court"), FunctionCallContent] is loaded via LoadInitialHistory; the resulting AgentChatHistoryItem.Contents contains only the FunctionCallContent.
LoadInitialHistory_PersistedAssistantTurnWithCourtInProse_RehydratesTextContentUnchanged AgentChatPersistenceTests Precision on read: legitimate prose containing the word "court" alongside a tool call is preserved.
LoadInitialHistory_PersistedToolOnlyAssistantTurnWithCourt_LogsDetectionWarning AgentChatPersistenceTests Read-side telemetry: rehydration of a poisoned turn fires a LogWarning referencing issue #1197.
PushUpdate_RootUpdateWithWholeContentCourtAlongsideFunctionCall_DoesNotWriteTextContentToRootWriter CopilotSubAgentRouterTests Router-level defence-in-depth: an update whose only TextContent is court and which is part of a tool-only turn does not surface as parent visible text.
PushUpdate_RootUpdateWithProseContainingCourt_WritesToRootWriterUnchanged CopilotSubAgentRouterTests Router precision: legitimate prose containing "court" is unaffected.
CopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_WholeContentCourtAndToolCall_ReturnsTrue CopilotStrayLiteralDetectorTests (new) Unit-level contract of the detector helper.
CopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_CourtSubstringInProse_ReturnsFalse CopilotStrayLiteralDetectorTests (new) Unit-level: substring, prefix, and mixed-case variants do not match.
CopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_CourtOnPureTextTurn_ReturnsFalse CopilotStrayLiteralDetectorTests (new) Unit-level: whole-content "court" without a FunctionCallContent does not match.

Repro guidance

Reproduces deterministically in any Copilot-CLI parent conversation that orchestrates background sub-agents with the parent calling read_agent (or any tool) with no preamble. Persisted evidence:

Select-String -Path 'C:\Users\<user>\.copilot\session-state\*\events.jsonl' `
              -Pattern '"content":"court"' -SimpleMatch |
    Select-Object -First 5 Line

Detection warnings from layers (a)/(b)/(c), once implemented, tag each occurrence with the session id so field-frequency can be measured and, if the CLI defect broadens to other literals, KnownStrayLiterals can be extended without touching the detection contract.

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