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:
- The assistant turn's entire visible text is the single token
court (whole-string, case-sensitive, after trim — not a substring).
- That same turn contains one or more tool calls (
FunctionCallContent).
- The turn otherwise carries no legitimate preamble text.
- The literal is deterministically
court — never any other word.
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-94 — AssistantMessageDeltaEvent → new 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-501 — PushUpdate 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-122 — PersistMessageAsync(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-1314 — LoadInitialHistory 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:
-
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);
}
-
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.
Stray assistant word (deterministically
court) is persisted by the GitHub Copilot CLI on tool-only turns — Phantom.Workspaces can only DETECT and MASK itSummary
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 beforeread_agentafter aSubagentCompletedsystem notification, but the same literalcourthas 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-textdata.contentof anassistant.messageevent 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 aGitHub.Copilot.SDKbug — 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_agentin this current session):Invariants of every
courtoccurrence:court(whole-string, case-sensitive, after trim — not a substring).FunctionCallContent).court— never any other word.courtsurvives reload because it is written intoevents.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):
assistant.messageeventscontent == "court"1b397e43-…0d796cb5-…2388a014-…c115e131-…Breakdown of the tool called on the
courtturns (representative session):read_agentgrepread_powershellpowershellreport_intentSo the stray
courtis not exclusive toread_agent; it appears on any tool-only turn whose visible-text content should have been empty.SubagentCompleted → read_agentis 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 withcontent:"", versus 96 poisoned withcontent:"court"— a rare stochastic replacement, but always the same literal.outputTokens:124on acontentof length 5 shows the model generated ≥120 tokens of reasoning / tool arguments; only "court" landed in the visible-text channel. The CLI redisplays the persistedcourton 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
courtdoes not exist in the Phantom.Workspacesfeatures/tree (whole-word, case-insensitive, across all.cs,.xaml,.json,.yaml,.md,.resx,.txt, prompts, tests, submodules — zero hits), nor in theGitHub.Copilot.SDK/Microsoft.Extensions.AI/OpenAI/Microsoft.ML.TokenizersNuGet 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.messagewithdata.content == "court"(either during streaming, or replayed fromevents.jsonlon session resume), it enters our code at the following seams. These are the sites where detection and masking must be applied:features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs:88-94—AssistantMessageDeltaEvent→new TextContent(delta.Data.DeltaContent)DeltaContentinto aTextContent. If the CLI's stream carried"court"for this turn, it becomes a visibleTextContent("court")here. Same site tags withagentId.features/Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs:475-501—PushUpdateempty-AgentIdbranch:if (string.IsNullOrEmpty(agentId)) { this.rootWriter.TryWrite(update); return; }TextContent("court")) flow straight to the parent transcript. No content-shape validation.features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:101-122—PersistMessageAsync(ChatMessage)callsstore.StoreAsyncwith the finalisedChatMessageincludingContents.[TextContent("court"), FunctionCallContent(...)], this persists that shape into Mongo verbatim, propagating the CLI defect into our own store.features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1298-1314—LoadInitialHistorycopiesmessage.Contentsstraight onto the newAgentChatHistoryItem.TextContent("court")beside aFunctionCallContentis rendered verbatim on session resume.Detection
The exact signature — designed to have effectively zero false positives — is:
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 oneFunctionCallContentpresent), 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):Detection must fire in two places:
On stream / write — at the point where the finalised assistant
ChatMessageis about to be persisted. Add a guard inStreamingPersistenceMiddleware.PersistMessageAsync(StreamingPersistenceMiddleware.cs:101-122) immediately beforestore.StoreAsync(...):On read / rehydrate — at
AgentChat.LoadInitialHistory(AgentChat.cs:1298-1314) so that already-poisonedevents.jsonlsessions and any already-poisoned Mongo documents stop renderingcourtin the GUI:Both sites emit a
LogWarningtagged 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
courtintodata.contenton 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:After stripping, the persisted message contains only
FunctionCallContentitems — 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 theAgentChatHistoryItem. This handles:courtinto~/.copilot/session-state/<sid>/events.jsonlprior to our rollout — the resume path stops rendering it in the GUI even though the CLI's own file is still polluted.(c) Router-level guard — defence-in-depth
CopilotSubAgentRouter.PushUpdateempty-AgentIdcatch-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 individualChatResponseUpdate: if the update carries exactly oneTextContentwhose trimmed text iscourtand the same update (or an update seen earlier in the sameMsggroup) carries aFunctionCallContenton the parent, drop theTextContent. This is a belt-and-braces measure for the in-flight case wherePersistMessageAsyncfinalisation 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 finalisedChatMessagein 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:
FunctionCallContentprevents 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).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
courtfor the empty string when finalising anassistant.messagewhose 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-diskevents.jsonlwithcontent:"court"co-located withtoolRequests[]; deterministic reproduction onSubagentCompleted → 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-AgentIdcatch-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 literalcourtobserved on disk — the CLI writes the literal intoevents.jsonlbefore Phantom.Workspaces sees it.CopilotSubAgentRouter.PushUpdate(CopilotSubAgentRouter.cs:475-501) unconditionally writes anyChatResponseUpdatewhose derivedagentIdis null/empty into the parent's visible-text sink (rootWriter).agentIdderives from the first non-emptyParentToolCallIdin the update's contents; every event kind inCopilotSdkStreamAdapterinherits the SDK's event-levelAgentIdverbatim (CopilotSdkStreamAdapter.cs:88-112, 185-195) with no fallback to nested carriers such asData.Kind.AgentId.Tag()(CopilotSdkStreamAdapter.cs:248-257) drops the property whenagentIdis empty, soGetParentToolCallIdreturnsnullandPushUpdatehits the catch-all.AgentIdpost-HandleSubAgentResultAsync(:419-467), parent reasoning delta reaching the transcript whenAgentViewModel.IsReasoningVisibleis on,ChildRoutingEntry.pendingrace withCompleteAsyncteardown, injected system-notification splitting an in-flight assistant delta.content:"court"in~/.copilot/…/events.jsonlpredates any Phantom.Workspaces routing. The router's empty-AgentIdcatch-all is still worth hardening as defence-in-depth (workaround layer (c) above and the router tests below), but it is not what putcourton the owner's screen.Affected Files (Phantom.Workspaces)
features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs(:88-94)TextContenttranslation. 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)store.StoreAsync. Add aMicrosoft.Extensions.Logging.ILoggerdependency on the middleware if not present.features/Phantom.Workspaces.Llm.Core/AgentChat.cs(:1298-1314,LoadInitialHistory)AddHistoryItem.features/Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs(:475-501,PushUpdateempty-AgentIdbranch)features/Phantom.Workspaces.Llm.Core/CopilotStrayLiteralDetector.cs(new)KnownStrayLiterals,IsStrayLiteralAssistantTurn, andStripVisibleTextInPlace. Shared between write, read, and router sites.features/Phantom.Workspaces.Data.MongoDB/MongoDbAgentPersistenceStore.csAgentChat.LoadInitialHistorycovers 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 theSubject_Scenario_ExpectedOutcomenaming already used there.PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCourt_StripsTextContentBeforeStoreStreamingPersistenceMiddlewareTestsChatMessageis[TextContent("court"), FunctionCallContent(read_agent)], the message passed tostore.StoreAsynccontains only theFunctionCallContent— theTextContent("court")is stripped in place.PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCourt_LogsWarningWithSessionAndMessageIdStreamingPersistenceMiddlewareTestsLogWarningreferencing session id, message id, and issue #1197.PersistMessageAsync_AssistantTurnContainingCourtInProse_LeavesTextContentUnchangedStreamingPersistenceMiddlewareTests[TextContent("The court adjourned"), FunctionCallContent(read_agent)]is persisted verbatim; no strip, no warning.PersistMessageAsync_PureTextAssistantTurnWithWholeContentCourt_LeavesTextContentUnchangedStreamingPersistenceMiddlewareTests[TextContent("court")]and NOFunctionCallContentis persisted verbatim (the gate on a tool call rules it out).PersistMessageAsync_ToolOnlyAssistantTurnWithWholeContentCOURT_DoesNotStripStreamingPersistenceMiddlewareTests"COURT"(all caps) is not stripped.LoadInitialHistory_PersistedToolOnlyAssistantTurnWithCourt_RehydratesWithoutVisibleTextContentAgentChatPersistenceTestsChatMessagewith[TextContent("court"), FunctionCallContent]is loaded viaLoadInitialHistory; the resultingAgentChatHistoryItem.Contentscontains only theFunctionCallContent.LoadInitialHistory_PersistedAssistantTurnWithCourtInProse_RehydratesTextContentUnchangedAgentChatPersistenceTestsLoadInitialHistory_PersistedToolOnlyAssistantTurnWithCourt_LogsDetectionWarningAgentChatPersistenceTestsLogWarningreferencing issue #1197.PushUpdate_RootUpdateWithWholeContentCourtAlongsideFunctionCall_DoesNotWriteTextContentToRootWriterCopilotSubAgentRouterTestsTextContentiscourtand which is part of a tool-only turn does not surface as parent visible text.PushUpdate_RootUpdateWithProseContainingCourt_WritesToRootWriterUnchangedCopilotSubAgentRouterTestsCopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_WholeContentCourtAndToolCall_ReturnsTrueCopilotStrayLiteralDetectorTests(new)CopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_CourtSubstringInProse_ReturnsFalseCopilotStrayLiteralDetectorTests(new)CopilotStrayLiteralDetector_IsStrayLiteralAssistantTurn_CourtOnPureTextTurn_ReturnsFalseCopilotStrayLiteralDetectorTests(new)FunctionCallContentdoes 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: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,
KnownStrayLiteralscan be extended without touching the detection contract.