Blocks v0.0.21
This failure is release-blocking for v0.0.21. The v0.0.21 tag was moved to the exact verified tip f04c8524bccf8aab60762806e3a1ccb7474e77c9 ("Fix Copilot validator native exit state", 2026-09-16 09:06:56 -0700), but the release workflow run 35132787733 failed at the Run test suite (release gate) step, so Publish and package win-x64 and Create GitHub Release never ran. No GitHub Release or assets exist for v0.0.21. A single test failed (7,425 passed / 1 failed / 7,426 executed).
Related history (do not reopen): #1313 (epic — end-to-end Copilot-SDK-BYOK persistence round-trip) and #1319 (the sub-item that authored this exact test) are closed. This is a new, distinct defect: the test's contract is correct, but it has a synchronization race against incremental persistence that #1319 did not cover. #1577 is a separate, unrelated intermittent AgentChatResumeTests NRE. This issue references them for lineage only.
Summary
Phantom.Workspaces.Transport.Tests.Scenarios.RemoteCopilotSdkSessionTests.RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSource intermittently fails at the persistence read-back assertion for the last message of turn 1 (FunctionResultContent with CallId == "call-shell-1"). The persisted collection is [first, turn-one, "", ""] — exactly the user message, the assistant message, the source tool-result, and the shell tool-call — with the final shell tool-result message missing.
The root cause is a genuine ordering race between two facts that are both by-design:
- Persistence of a turn's final message lags live history by exactly one update.
StreamingPersistenceMiddleware persists a message only once it is "stable" — i.e. once the next update begins, or once an update carrying FinishReason arrives. The CopilotSdkStreamAdapter emits that terminal FinishReason=Stop update on SessionIdleEvent specifically so the last message becomes durable. So a message appears in live chat.History one update before it is persisted.
- Disposal cancels the process loop before it drains that terminal update.
AgentChat.DisposeCoreAsync calls await this.cts.CancelAsync() before awaiting processTask, and cts.Token is the token threaded into the streaming pull inside the middleware.
The test synchronizes on live chat1.History reaching 5 (WaitForHistoryCountAsync, line 192) and then immediately closes chat1 (end of the await using block). If the process loop has not yet pulled + persisted the terminal FinishReason update by the time cancellation fires, the last message (call-shell-1 tool-result) is never written, and the subsequent SourceStore.ReadMessagesAsync (line 203) returns only 4 messages. Under CI thread-pool contention (the middleware offloads each pull via Task.Run) plus the added in-process wire latency of the transport topology, the disposal-cancellation wins the race — which is why it reproduces only under the release-CI environment/order and not in the local exact-tip full gate (7,658/7,658).
Classification: primarily a test/harness synchronization defect (the assertion races persistence it cannot observe yet), which also exposes a latent product data-loss window — disposing an AgentChat immediately after a turn's last message appears can drop that message from persisted history. Both the test-first fix and the product hardening are described below.
Observed vs Expected
Observed (release gate, run 35132787733):
FAIL: Phantom.Workspaces.Transport.Tests.Scenarios.RemoteCopilotSdkSessionTests.RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSource
Assert.Contains() Failure: filter not matched in collection
Collection (persisted messages read back from SourceStore): [first, turn-one, "", ""]
The failing assertion is line 208:
Assert.Contains(messages, m => m.Contents.OfType<FunctionResultContent>().Any(r => r.CallId == "call-shell-1"));
The four persisted messages are: user "first", assistant "turn-one"+FunctionCallContent(call-source-1), tool FunctionResultContent(call-source-1) (""), assistant FunctionCallContent(call-shell-1) (""). The fifth message — tool FunctionResultContent(call-shell-1) — is missing. (Lines 205–207 pass because those contents are in the first four messages; line 208 is the first assertion touching content that lives only in the un-persisted fifth message.)
Expected: All of turn 1's messages, including the final FunctionResultContent(call-shell-1), are durably persisted to the source InMemoryAgentPersistenceStore before the round-trip is asserted, so the read-back contains the complete transcript and (line 227–230) the reopened chat2.History restores it in full.
Reproduction
- Run the fast suite (
scripts/run-tests.ps1 -Mode fast) under load / high parallelism (release CI or a loaded dev box), or add artificial thread-pool starvation.
- The turn-1 event script is: assistant delta
turn-one → ToolExecutionStart/Complete(call-source-1) → ToolExecutionStart/Complete(call-shell-1) → SessionIdle.
WaitForHistoryCountAsync(chat1.History, 5, ...) (line 192) returns as soon as the call-shell-1 result update is yielded into live history — which is before the middleware pulls the subsequent terminal FinishReason update that would persist it.
- The
await using (var chat1 …) block closes → DisposeAsync → cts.CancelAsync() (AgentChat.cs:1746) fires before the process loop drains the terminal update.
SourceStore.ReadMessagesAsync (line 203) then returns [first, turn-one, "", ""] and line 208 fails.
Deterministic local repro (for the fix author): after WaitForHistoryCountAsync(chat1.History, 5, …), inject a Task.Delay/yield-starvation before the terminal update is pulled, or dispose chat1 on a starved scheduler, and the final tool-result is reliably dropped.
Evidence / Log excerpt
Release gate step Run test suite (release gate) (run 35132787733, job 104917619366):
=== Test Run Summary ===
Executed : 7426
Passed : 7425
Failed : 1
FAIL [runneradmin_runnervmvmocb_2026-09-16_18_46_45_net10.0]: 1 test(s) failed:
Phantom.Workspaces.Transport.Tests.Scenarios.RemoteCopilotSdkSessionTests.RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSource
##[error]Process completed with exit code 1.
Downstream Publish and package win-x64 and Create GitHub Release were skipped; earlier steps (Build, version derive, SDK pin assert, MXC native unit) all passed. Earlier local exact-tip full release gate passed 7,658/7,658, confirming the failure is non-deterministic and environment/order-sensitive rather than a hard regression.
Root Cause
The persisted collection [first, turn-one, "", ""] is missing exactly one message: the final tool-result. That is the fingerprint of "the turn's last message reached live history but was never persisted." Three code facts combine to make this happen on immediate dispose:
1. The middleware persists the last message only on a terminal FinishReason update.
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:93-100:
var response = buffer.ToChatResponse();
var stableCount = update.FinishReason is not null
? response.Messages.Count // final: persist ALL messages (incl. the last)
: Math.Max(0, response.Messages.Count - 1); // in-flight: last message is NOT yet stable
for (var i = persistedCount; i < stableCount; i++)
await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false);
When the call-shell-1 result update arrives, FinishReason is still null, so stableCount = messagesCount - 1 and the call-shell-1 result message (the last one) is not persisted. It is yielded to the consumer (advancing live History to 5) but only becomes durable when the next update — the terminal one — is pulled.
2. The adapter's terminal FinishReason update is what makes the last message durable.
features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs:263-274 (on SessionIdleEvent):
// Emit a terminal update carrying FinishReason so downstream middleware
// (StreamingPersistenceMiddleware) treats the response as final and persists
// the last message of the turn. Without this the last message of every
// Copilot turn is treated as unstable and never persisted (issue #1103).
yield return new ChatResponseUpdate { Role = ChatRole.Assistant, FinishReason = ChatFinishReason.Stop };
yield break;
This update must be pulled across the transport (remote ChatClientTransportListener → wire → source ChatClientOverTransport → StreamingPersistenceMiddleware) for call-shell-1's result to be written. IncrementalPersistenceChatHistoryProvider.StoreChatHistoryAsync is a deliberate no-op — the middleware is the sole writer of response messages — so if the terminal update is not drained, nothing else persists the last message.
3. Disposal cancels the drain.
features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1746-1767 (DisposeCoreAsync):
await this.cts.CancelAsync(); // cancels the process-loop token FIRST
...
try { await this.processTask; } catch (OperationCanceledException) { }
processTask runs RunProcessLoopAsync(this.cts.Token) (AgentChat.cs:2847-2851), and that same cts.Token flows into the streaming pipeline, including the middleware's Task.Run(() => enumerator.MoveNextAsync(), cancellationToken). Cancelling the CTS before the loop drains the terminal update aborts the pull, so call-shell-1's result is never persisted.
The race window: WaitForHistoryCountAsync(chat1.History, 5, …) (test line 192) returns the instant the call-shell-1 result update is yielded — i.e. after fact (1), before facts (2)/(3) complete. The test then disposes chat1 (fact 3's cancellation) which can beat the drain (fact 2). Under load + wire latency the cancellation wins → missing final message.
Affected Files
| File |
Role in the defect |
features/Phantom.Workspaces.Transport.Tests/Scenarios/RemoteCopilotSdkSessionTests.cs |
The flaky test. Synchronizes on live chat1.History count (line 192) then disposes chat1 and reads persistence (lines 203–208), racing the one-update-late persistence of the final message. Primary fix site. |
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs |
stableCount logic (lines 93–100): the last message is persisted only on a terminal FinishReason update, so persistence legitimately lags live history by one update. |
features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs |
Emits the terminal FinishReason=Stop update on SessionIdleEvent (lines 263–274) that the middleware needs to persist the last message. |
features/Phantom.Workspaces.Llm.Core/AgentChat.cs |
DisposeCoreAsync cancels this.cts (line 1746) before awaiting processTask (line 1767); cts.Token drives the process loop (2847–2851) and the streaming pull. Product-hardening site. |
features/Phantom.Workspaces.Llm.Core/IncrementalPersistenceChatHistoryProvider.cs |
StoreChatHistoryAsync is a no-op (sole-writer contract), so nothing backstops the middleware if its final drain is cancelled. |
Design / Fix
Option A — Test-first synchronization fix (primary, lowest risk)
Make the test wait for persistence to reflect the turn's final message, rather than for live history. Add a store-polling helper and use it before disposing chat1 / before the read-back asserts:
// After WaitForHistoryCountAsync(chat1.History, 5, ...):
await WaitForPersistedAsync(
() => setup.SourceStore.ReadMessagesAsync(new ReadMessagesRequest { AgentSessionId = chat1.AgentSessionId }, ct),
msgs => msgs.Any(m => m.Contents.OfType<FunctionResultContent>().Any(r => r.CallId == "call-shell-1")),
"source store to persist the final call-shell-1 tool-result",
timeoutSeconds: 60);
WaitForPersistedAsync polls the store (same shape as the existing WaitForConditionAsync, lines 391–410) until the final tool-result is durable, then the await using close and read-back are deterministic. This removes the flake without asserting anything the product does not already guarantee once the turn fully drains.
Option B — Product hardening (recommended in addition; closes the latent data-loss window)
Guarantee that disposal drains the in-flight (already-complete) turn's persistence before cancelling. In AgentChat.DisposeCoreAsync, give the current turn a bounded grace period to reach natural stream completion before cts.CancelAsync() (e.g. await processTask with a timeout on a graceful-shutdown token, then hard-cancel). This ensures that disposing a chat immediately after a turn's last message appears in History does not drop that message from persisted history in production, not just in tests.
Considered / background: persisting the last buffered message from the middleware's finally block on cancellation was rejected — on a genuine mid-token cancellation the last buffered message is not yet complete, so flushing it could persist a half-streamed message. The terminal-FinishReason signal is the only reliable "last message is complete" marker, which is why the fix belongs at the drain/lifecycle level (Option B) and/or the test-sync level (Option A), not in an unconditional cancel-time flush.
Recommended resolution: ship A to unblock v0.0.21 deterministically, and land B to eliminate the underlying data-loss window.
Expected Tests
| Test Name |
Class |
What It Verifies |
RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSource |
RemoteCopilotSdkSessionTests |
Hardened to poll SourceStore.ReadMessagesAsync for FunctionResultContent(call-shell-1) before closing chat1 and asserting, so the final tool-result round-trips deterministically (no dependence on drain-vs-dispose timing). |
AgentChat_DisposeImmediatelyAfterTurnCompletes_PersistsFinalTurnMessage |
AgentChatPersistenceIntegrationTests |
Disposing an AgentChat immediately after a turn's last message appears in live History still persists that final message (drains the terminal FinishReason update before cancelling) — asserts Option B at the product level. |
StreamingPersistenceMiddleware_LastMessageStabilizedByTerminalFinishReason_IsPersistedBeforeStreamCompletes |
StreamingPersistenceMiddlewareTests |
With a scripted stream ending in a terminal FinishReason update, the final message is persisted; and if the consumer stops pulling before the terminal update, the final message is (documented as) not yet durable — pinning the one-update-late contract the race exploits. |
Acceptance Criteria
Filed by the file-bug role. Failure source: release workflow run 35132787733 on tip f04c8524bccf8aab60762806e3a1ccb7474e77c9. Lineage: #1313, #1319 (closed; not reopened), #1577 (unrelated intermittent NRE).
Blocks v0.0.21
This failure is release-blocking for v0.0.21. The v0.0.21 tag was moved to the exact verified tip
f04c8524bccf8aab60762806e3a1ccb7474e77c9("Fix Copilot validator native exit state", 2026-09-16 09:06:56 -0700), but the release workflow run 35132787733 failed at the Run test suite (release gate) step, so Publish and package win-x64 and Create GitHub Release never ran. No GitHub Release or assets exist for v0.0.21. A single test failed (7,425 passed / 1 failed / 7,426 executed).Summary
Phantom.Workspaces.Transport.Tests.Scenarios.RemoteCopilotSdkSessionTests.RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSourceintermittently fails at the persistence read-back assertion for the last message of turn 1 (FunctionResultContentwithCallId == "call-shell-1"). The persisted collection is[first, turn-one, "", ""]— exactly the user message, the assistant message, the source tool-result, and the shell tool-call — with the final shell tool-result message missing.The root cause is a genuine ordering race between two facts that are both by-design:
StreamingPersistenceMiddlewarepersists a message only once it is "stable" — i.e. once the next update begins, or once an update carryingFinishReasonarrives. TheCopilotSdkStreamAdapteremits that terminalFinishReason=Stopupdate onSessionIdleEventspecifically so the last message becomes durable. So a message appears in livechat.Historyone update before it is persisted.AgentChat.DisposeCoreAsynccallsawait this.cts.CancelAsync()before awaitingprocessTask, andcts.Tokenis the token threaded into the streaming pull inside the middleware.The test synchronizes on live
chat1.Historyreaching 5 (WaitForHistoryCountAsync, line 192) and then immediately closeschat1(end of theawait usingblock). If the process loop has not yet pulled + persisted the terminalFinishReasonupdate by the time cancellation fires, the last message (call-shell-1tool-result) is never written, and the subsequentSourceStore.ReadMessagesAsync(line 203) returns only 4 messages. Under CI thread-pool contention (the middleware offloads each pull viaTask.Run) plus the added in-process wire latency of the transport topology, the disposal-cancellation wins the race — which is why it reproduces only under the release-CI environment/order and not in the local exact-tip full gate (7,658/7,658).Classification: primarily a test/harness synchronization defect (the assertion races persistence it cannot observe yet), which also exposes a latent product data-loss window — disposing an
AgentChatimmediately after a turn's last message appears can drop that message from persisted history. Both the test-first fix and the product hardening are described below.Observed vs Expected
Observed (release gate, run 35132787733):
The failing assertion is line 208:
The four persisted messages are: user
"first", assistant"turn-one"+FunctionCallContent(call-source-1), toolFunctionResultContent(call-source-1)(""), assistantFunctionCallContent(call-shell-1)(""). The fifth message — toolFunctionResultContent(call-shell-1)— is missing. (Lines 205–207 pass because those contents are in the first four messages; line 208 is the first assertion touching content that lives only in the un-persisted fifth message.)Expected: All of turn 1's messages, including the final
FunctionResultContent(call-shell-1), are durably persisted to the sourceInMemoryAgentPersistenceStorebefore the round-trip is asserted, so the read-back contains the complete transcript and (line 227–230) the reopenedchat2.Historyrestores it in full.Reproduction
scripts/run-tests.ps1 -Mode fast) under load / high parallelism (release CI or a loaded dev box), or add artificial thread-pool starvation.turn-one→ToolExecutionStart/Complete(call-source-1)→ToolExecutionStart/Complete(call-shell-1)→SessionIdle.WaitForHistoryCountAsync(chat1.History, 5, ...)(line 192) returns as soon as thecall-shell-1result update is yielded into live history — which is before the middleware pulls the subsequent terminalFinishReasonupdate that would persist it.await using (var chat1 …)block closes →DisposeAsync→cts.CancelAsync()(AgentChat.cs:1746) fires before the process loop drains the terminal update.SourceStore.ReadMessagesAsync(line 203) then returns[first, turn-one, "", ""]and line 208 fails.Deterministic local repro (for the fix author): after
WaitForHistoryCountAsync(chat1.History, 5, …), inject aTask.Delay/yield-starvation before the terminal update is pulled, or disposechat1on a starved scheduler, and the final tool-result is reliably dropped.Evidence / Log excerpt
Release gate step Run test suite (release gate) (run 35132787733, job 104917619366):
Downstream
Publish and package win-x64andCreate GitHub Releasewere skipped; earlier steps (Build, version derive, SDK pin assert, MXC native unit) all passed. Earlier local exact-tip full release gate passed 7,658/7,658, confirming the failure is non-deterministic and environment/order-sensitive rather than a hard regression.Root Cause
The persisted collection
[first, turn-one, "", ""]is missing exactly one message: the final tool-result. That is the fingerprint of "the turn's last message reached live history but was never persisted." Three code facts combine to make this happen on immediate dispose:1. The middleware persists the last message only on a terminal
FinishReasonupdate.features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:93-100:When the
call-shell-1result update arrives,FinishReasonis stillnull, sostableCount = messagesCount - 1and thecall-shell-1result message (the last one) is not persisted. It is yielded to the consumer (advancing liveHistoryto 5) but only becomes durable when the next update — the terminal one — is pulled.2. The adapter's terminal
FinishReasonupdate is what makes the last message durable.features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs:263-274(onSessionIdleEvent):This update must be pulled across the transport (remote
ChatClientTransportListener→ wire → sourceChatClientOverTransport→StreamingPersistenceMiddleware) forcall-shell-1's result to be written.IncrementalPersistenceChatHistoryProvider.StoreChatHistoryAsyncis a deliberate no-op — the middleware is the sole writer of response messages — so if the terminal update is not drained, nothing else persists the last message.3. Disposal cancels the drain.
features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1746-1767(DisposeCoreAsync):processTaskrunsRunProcessLoopAsync(this.cts.Token)(AgentChat.cs:2847-2851), and that samects.Tokenflows into the streaming pipeline, including the middleware'sTask.Run(() => enumerator.MoveNextAsync(), cancellationToken). Cancelling the CTS before the loop drains the terminal update aborts the pull, socall-shell-1's result is never persisted.The race window:
WaitForHistoryCountAsync(chat1.History, 5, …)(test line 192) returns the instant thecall-shell-1result update is yielded — i.e. after fact (1), before facts (2)/(3) complete. The test then disposeschat1(fact 3's cancellation) which can beat the drain (fact 2). Under load + wire latency the cancellation wins → missing final message.Affected Files
features/Phantom.Workspaces.Transport.Tests/Scenarios/RemoteCopilotSdkSessionTests.cschat1.Historycount (line 192) then disposeschat1and reads persistence (lines 203–208), racing the one-update-late persistence of the final message. Primary fix site.features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.csstableCountlogic (lines 93–100): the last message is persisted only on a terminalFinishReasonupdate, so persistence legitimately lags live history by one update.features/Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.csFinishReason=Stopupdate onSessionIdleEvent(lines 263–274) that the middleware needs to persist the last message.features/Phantom.Workspaces.Llm.Core/AgentChat.csDisposeCoreAsynccancelsthis.cts(line 1746) before awaitingprocessTask(line 1767);cts.Tokendrives the process loop (2847–2851) and the streaming pull. Product-hardening site.features/Phantom.Workspaces.Llm.Core/IncrementalPersistenceChatHistoryProvider.csStoreChatHistoryAsyncis a no-op (sole-writer contract), so nothing backstops the middleware if its final drain is cancelled.Design / Fix
Option A — Test-first synchronization fix (primary, lowest risk)
Make the test wait for persistence to reflect the turn's final message, rather than for live history. Add a store-polling helper and use it before disposing
chat1/ before the read-back asserts:WaitForPersistedAsyncpolls the store (same shape as the existingWaitForConditionAsync, lines 391–410) until the final tool-result is durable, then theawait usingclose and read-back are deterministic. This removes the flake without asserting anything the product does not already guarantee once the turn fully drains.Option B — Product hardening (recommended in addition; closes the latent data-loss window)
Guarantee that disposal drains the in-flight (already-complete) turn's persistence before cancelling. In
AgentChat.DisposeCoreAsync, give the current turn a bounded grace period to reach natural stream completion beforects.CancelAsync()(e.g. awaitprocessTaskwith a timeout on a graceful-shutdown token, then hard-cancel). This ensures that disposing a chat immediately after a turn's last message appears inHistorydoes not drop that message from persisted history in production, not just in tests.Recommended resolution: ship A to unblock v0.0.21 deterministically, and land B to eliminate the underlying data-loss window.
Expected Tests
RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSourceRemoteCopilotSdkSessionTestsSourceStore.ReadMessagesAsyncforFunctionResultContent(call-shell-1)before closingchat1and asserting, so the final tool-result round-trips deterministically (no dependence on drain-vs-dispose timing).AgentChat_DisposeImmediatelyAfterTurnCompletes_PersistsFinalTurnMessageAgentChatPersistenceIntegrationTestsAgentChatimmediately after a turn's last message appears in liveHistorystill persists that final message (drains the terminalFinishReasonupdate before cancelling) — asserts Option B at the product level.StreamingPersistenceMiddleware_LastMessageStabilizedByTerminalFinishReason_IsPersistedBeforeStreamCompletesStreamingPersistenceMiddlewareTestsFinishReasonupdate, the final message is persisted; and if the consumer stops pulling before the terminal update, the final message is (documented as) not yet durable — pinning the one-update-late contract the race exploits.Acceptance Criteria
RemoteCopilotSdkSession_HistoryAndPersistenceRoundTripOnSourcepasses deterministically, including under thread-pool starvation / high parallelism (e.g. 50+ consecutive and stress runs green).chat2.Historyrestore (lines 226–230) both contain the complete turn-1 transcript, includingFunctionResultContent(call-shell-1).AgentChatimmediately after a turn completes does not drop the turn's final message from persisted history, proven byAgentChat_DisposeImmediatelyAfterTurnCompletes_PersistsFinalTurnMessage.scripts/run-tests.ps1 -Mode fast) passes with 0 failures on the exact tip so Publish and package win-x64 and Create GitHub Release can run.IncrementalPersistenceChatHistoryProvider.StoreChatHistoryAsyncstays a no-op); no unconditional cancel-time flush of unstable messages.Filed by the file-bug role. Failure source: release workflow run 35132787733 on tip
f04c8524bccf8aab60762806e3a1ccb7474e77c9. Lineage: #1313, #1319 (closed; not reopened), #1577 (unrelated intermittent NRE).