You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
During Copilot SDK session reload/restore, AgentChat.MarkRestoredSubAgentTerminalAsync materialises each persisted sub-agent (acquires its lease, which loads the child's transcript from the persistence store) and forces it to the terminal Succeeded state added by #1128. That whole operation is wrapped in a bare catch { } that silently discards every exception with no logging, no diagnostic, and no rethrow. If a restored sub-agent fails to materialise — a corrupt/undeserialisable persisted session, a store read failure, a factory error, or any exception from AcquireLeaseAsync — the failure is completely invisible: the sub-agent silently stays in its default (Running) state, the #1128 forced-Succeeded transition never happens (so the UI shows a perpetual pulsating-brain / running marker), and there is no log line, error diagnostic, or surfaced fault anywhere to indicate that a child transcript failed to load. Operators and developers get a silently-degraded restore with no signal.
This is a robustness / observability defect on the restore path, distinct from the persistence mechanism itself: the transcript-persistence and reload machinery is otherwise correct (see "Not a defect" note below), but its failure mode during restore is swallowed.
privateasyncTaskMarkRestoredSubAgentTerminalAsync(SubAgentstub,CancellationTokencancellationToken){try{varlease=awaitstub.AcquireLeaseAsync(cancellationToken).ConfigureAwait(false);// Keep the lease alive for as long as this parent lives so the terminal// completionStateOverride persists across other lease acquisitions.this.RegisterOwnedResource(lease);lease.AgentChat.SetCompletionState(AgentChatCompletionState.Succeeded);}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){}catch{// Best-effort: leaving the sub-agent in its default state is preferable to// faulting parent restore.}}
The try body wraps the child materialisation / transcript load (stub.AcquireLeaseAsync → IRunningAgentChatFactory.GetAsync → AgentChat.CreateAsync → InitializeAsync → RestoreAsync + ReadMessagesAsync + LoadInitialHistory) as well as lease registration and the Reloading a Copilot SDK session should mark all restored SDK sub-agents completed #1128SetCompletionState(Succeeded) call.
The first catch correctly and narrowly handles cooperative cancellation.
The second catch is bare (line 1205-1209): it catches all exception types, contains no ILogger/Console/Trace call, does not rethrow, and does not surface any diagnostic. It swallows the failure entirely.
RestoreSubAgentsAsync (AgentChat.cs:1121-1168) invokes this as fire-and-forget work (this.restoredSubAgentTerminalTasks.Add(terminalTask), AgentChat.cs:1162-1166), so even a faulted Task is never observed except by the test-only WaitForRestoredSubAgentsMarkedTerminalAsync (AgentChat.cs:1178-1186). In production nothing awaits or inspects these tasks, compounding the silent-swallow: a materialisation failure produces neither an exception, a log, nor a faulted-task observation.
The design intent ("leaving the sub-agent in its default state is preferable to faulting parent restore") is reasonable — a single bad child should not abort the whole parent restore — but swallowing without any observability is the defect. The correct behaviour is to keep restore resilient and surface the failure (log it, and/or emit a diagnostic on the affected sub-agent), so that a silently-empty / silently-stuck-Running sub-agent is diagnosable rather than invisible.
Not a defect (verified during diagnosis)
A prior hypothesis claimed completed hosted sub-agent transcripts are never persisted under a reloadable child record. This was verified false. Every AgentChat, including a hosted sub-agent, is given the parent's ConfiguredStore (AgentChatFactory.cs:155-164) and wraps its resolved client in a StreamingPersistenceMiddleware over an IncrementalPersistenceChatHistoryProvider (AgentChat.cs:277-281) that writes the child's AgentSessionJson + NewMessages to the store under the child's own AgentSessionId (IncrementalPersistenceChatHistoryProvider.cs:135-147). The hosted run streams through that middleware (RunHostedProcessLoopAsync → StartRun → session.RunStreamAsync over ChatClientAgent(streamingMiddleware, …), AgentChat.cs:1815-1819, 2003-2004, 304). On restore, InitializeAsync reloads it via RestoreAsync + ReadMessagesAsync + LoadInitialHistory (AgentChat.cs:342-345, 376), and the existing test AgentChatPersistenceTests.InitializeAsync_RestoredSubAgent_ChatHistoryLoaded asserts the restored child History.Count > 0. So transcript persistence/reload itself works; only its failure path during restore is swallowed, which is what this issue addresses.
Affected Files
File
Contribution
Phantom.Workspaces.Llm.Core\AgentChat.cs
MarkRestoredSubAgentTerminalAsync (1188-1210) — bare catch (1205-1209) silently swallows all child-materialisation / transcript-load / terminal-transition failures with no logging or diagnostic. RestoreSubAgentsAsync (1121-1168) schedules it fire-and-forget (1162-1166); the resulting Tasks are only observed by the test-only WaitForRestoredSubAgentsMarkedTerminalAsync (1178-1186), never in production.
Phantom.Workspaces.Llm.Core\SubAgent.cs
AcquireLeaseAsync materialises the child (factory.GetAsync), the operation whose exceptions are swallowed; a materialisation failure leaves the stub un-materialised and stuck in the default Running state.
Design / Fix
Goal: keep restore resilient (a single failing sub-agent must not fault the whole parent restore, preserving #1128's per-child best-effort behaviour) while making the failure observable instead of silently discarded.
1 — Replace the bare catch with an observing catch. Capture the exception and surface it. AgentChat does not currently hold an ILogger, but it already has a first-class user-visible diagnostic channel used throughout the class — AddHistoryItem / EnqueueSystemNote with AgentChatHistoryItem.DiagnosticChatRole and ErrorContent (e.g. AgentChat.cs:698, 840, and the hosted-loop error diagnostic at 1840-1856). Emit a diagnostic (on the parent, identifying the child by session id) and/or a log so the failure is visible:
catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){}catch(Exceptionex){// Best-effort: do not fault parent restore for one bad child, but the failure// MUST be observable (issue #<THIS>). Surface a diagnostic identifying the child.this.AddHistoryItem(newAgentChatHistoryItem{Role=AgentChatHistoryItem.DiagnosticChatRole,Contents=[newErrorContent($"Failed to restore sub-agent '{stub.AgentSessionId}': {ex.Message}")],Timestamp=this.timeProvider.GetUtcNow(),});// and/or ILogger.LogError(ex, ...) if a logger is threaded into AgentChat.}
2 — Do not leave a failed restored sub-agent silently Running. Because the whole point of #1128 is that a reloaded sub-agent has no live SDK run to ever move it out of the default Running state, a materialisation failure that skips SetCompletionState(Succeeded) leaves the UI showing a perpetual running marker. On the failure path, still drive the stub to a terminal state so the running indicator clears (e.g. mark it Failed via the stub, or force Succeeded on a best-effort re-attempt), consistent with #1128's requirement that restored sub-agents resolve to a terminal state.
3 — Observe the fire-and-forget tasks.RestoreSubAgentsAsync stores each terminalTask only for the test helper; in production a faulted task is never observed. Attach a continuation (or await within the session-init flow) that logs/surfaces faults so a swallowed-then-faulted task cannot disappear.
When AcquireLeaseAsync throws during restore (e.g. store/deserialisation failure), the failure is surfaced as a DiagnosticChatRole/ErrorContent history item (or logged), not silently discarded.
A single failing restored sub-agent does not fault the parent restore; the parent completes initialisation and other sub-agents still restore (preserves the best-effort intent).
A restored sub-agent whose materialisation fails is still driven to a terminal state so the UI running indicator clears rather than showing a perpetual Running (reconciles with #1128).
(closes the round-trip coverage gap) A hosted sub-agent that streamed assistant text and tool calls through the SDK/persistence middleware, when persisted and then reloaded via restore, exposes both the original assistant text and the tool-call items in the restored child's History — asserting content, not just History.Count > 0.
Summary
During Copilot SDK session reload/restore,
AgentChat.MarkRestoredSubAgentTerminalAsyncmaterialises each persisted sub-agent (acquires its lease, which loads the child's transcript from the persistence store) and forces it to the terminalSucceededstate added by #1128. That whole operation is wrapped in a barecatch { }that silently discards every exception with no logging, no diagnostic, and no rethrow. If a restored sub-agent fails to materialise — a corrupt/undeserialisable persisted session, a store read failure, a factory error, or any exception fromAcquireLeaseAsync— the failure is completely invisible: the sub-agent silently stays in its default (Running) state, the #1128 forced-Succeededtransition never happens (so the UI shows a perpetual pulsating-brain / running marker), and there is no log line, error diagnostic, or surfaced fault anywhere to indicate that a child transcript failed to load. Operators and developers get a silently-degraded restore with no signal.This is a robustness / observability defect on the restore path, distinct from the persistence mechanism itself: the transcript-persistence and reload machinery is otherwise correct (see "Not a defect" note below), but its failure mode during restore is swallowed.
Root Cause
Phantom.Workspaces.Llm.Core\AgentChat.cs:1188-1210—MarkRestoredSubAgentTerminalAsync:trybody wraps the child materialisation / transcript load (stub.AcquireLeaseAsync→IRunningAgentChatFactory.GetAsync→AgentChat.CreateAsync→InitializeAsync→RestoreAsync+ReadMessagesAsync+LoadInitialHistory) as well as lease registration and the Reloading a Copilot SDK session should mark all restored SDK sub-agents completed #1128SetCompletionState(Succeeded)call.catchcorrectly and narrowly handles cooperative cancellation.catchis bare (line 1205-1209): it catches all exception types, contains noILogger/Console/Tracecall, does not rethrow, and does not surface any diagnostic. It swallows the failure entirely.RestoreSubAgentsAsync(AgentChat.cs:1121-1168) invokes this as fire-and-forget work (this.restoredSubAgentTerminalTasks.Add(terminalTask),AgentChat.cs:1162-1166), so even a faultedTaskis never observed except by the test-onlyWaitForRestoredSubAgentsMarkedTerminalAsync(AgentChat.cs:1178-1186). In production nothing awaits or inspects these tasks, compounding the silent-swallow: a materialisation failure produces neither an exception, a log, nor a faulted-task observation.The design intent ("leaving the sub-agent in its default state is preferable to faulting parent restore") is reasonable — a single bad child should not abort the whole parent restore — but swallowing without any observability is the defect. The correct behaviour is to keep restore resilient and surface the failure (log it, and/or emit a diagnostic on the affected sub-agent), so that a silently-empty / silently-stuck-Running sub-agent is diagnosable rather than invisible.
Not a defect (verified during diagnosis)
A prior hypothesis claimed completed hosted sub-agent transcripts are never persisted under a reloadable child record. This was verified false. Every
AgentChat, including a hosted sub-agent, is given the parent'sConfiguredStore(AgentChatFactory.cs:155-164) and wraps its resolved client in aStreamingPersistenceMiddlewareover anIncrementalPersistenceChatHistoryProvider(AgentChat.cs:277-281) that writes the child'sAgentSessionJson+NewMessagesto the store under the child's ownAgentSessionId(IncrementalPersistenceChatHistoryProvider.cs:135-147). The hosted run streams through that middleware (RunHostedProcessLoopAsync→StartRun→session.RunStreamAsyncoverChatClientAgent(streamingMiddleware, …),AgentChat.cs:1815-1819,2003-2004,304). On restore,InitializeAsyncreloads it viaRestoreAsync+ReadMessagesAsync+LoadInitialHistory(AgentChat.cs:342-345,376), and the existing testAgentChatPersistenceTests.InitializeAsync_RestoredSubAgent_ChatHistoryLoadedasserts the restored childHistory.Count > 0. So transcript persistence/reload itself works; only its failure path during restore is swallowed, which is what this issue addresses.Affected Files
Phantom.Workspaces.Llm.Core\AgentChat.csMarkRestoredSubAgentTerminalAsync(1188-1210) — barecatch(1205-1209) silently swallows all child-materialisation / transcript-load / terminal-transition failures with no logging or diagnostic.RestoreSubAgentsAsync(1121-1168) schedules it fire-and-forget (1162-1166); the resultingTasks are only observed by the test-onlyWaitForRestoredSubAgentsMarkedTerminalAsync(1178-1186), never in production.Phantom.Workspaces.Llm.Core\SubAgent.csAcquireLeaseAsyncmaterialises the child (factory.GetAsync), the operation whose exceptions are swallowed; a materialisation failure leaves the stub un-materialised and stuck in the defaultRunningstate.Design / Fix
Goal: keep restore resilient (a single failing sub-agent must not fault the whole parent restore, preserving #1128's per-child best-effort behaviour) while making the failure observable instead of silently discarded.
1 — Replace the bare
catchwith an observing catch. Capture the exception and surface it.AgentChatdoes not currently hold anILogger, but it already has a first-class user-visible diagnostic channel used throughout the class —AddHistoryItem/EnqueueSystemNotewithAgentChatHistoryItem.DiagnosticChatRoleandErrorContent(e.g.AgentChat.cs:698,840, and the hosted-loop error diagnostic at1840-1856). Emit a diagnostic (on the parent, identifying the child by session id) and/or a log so the failure is visible:2 — Do not leave a failed restored sub-agent silently
Running. Because the whole point of #1128 is that a reloaded sub-agent has no live SDK run to ever move it out of the defaultRunningstate, a materialisation failure that skipsSetCompletionState(Succeeded)leaves the UI showing a perpetual running marker. On the failure path, still drive the stub to a terminal state so the running indicator clears (e.g. mark itFailedvia the stub, or forceSucceededon a best-effort re-attempt), consistent with #1128's requirement that restored sub-agents resolve to a terminal state.3 — Observe the fire-and-forget tasks.
RestoreSubAgentsAsyncstores eachterminalTaskonly for the test helper; in production a faulted task is never observed. Attach a continuation (or await within the session-init flow) that logs/surfaces faults so a swallowed-then-faulted task cannot disappear.Reconciliation with #1128 / #1140 / #1139
Succeeded): this fix preserves that behaviour on the success path and extends it to the failure path (still resolve to a terminal state so indicators clear), while making failures observable. Thetrybody being fixed is exactly the Reloading a Copilot SDK session should mark all restored SDK sub-agents completed #1128 transition.lastUpdatedAt; the success path is unchanged, so Reloading a session bumps completed sub-agents' last-updated time to the reload time (should preserve original) #1140'spreserveLastUpdatedAtbehaviour is unaffected.Expected Tests
Reuse existing classes/style (xUnit
[Fact],Subject_Scenario_ExpectedOutcome) fromAgentChatResumeTests,AgentChatHostedSubAgentTests, andAgentChatPersistenceTestsinPhantom.Workspaces.Llm.Core.Tests(helpers:InMemoryAgentPersistenceStore,DeterministicTestChatClient,CapturingTaskScheduler,AgentChatFactory,WaitForRestoredSubAgentsMarkedTerminalAsync).AgentChat_Resume_SubAgentMaterializationFails_SurfacesDiagnostic_NotSwallowedAgentChatResumeTestsAcquireLeaseAsyncthrows during restore (e.g. store/deserialisation failure), the failure is surfaced as aDiagnosticChatRole/ErrorContenthistory item (or logged), not silently discarded.AgentChat_Resume_SubAgentMaterializationFails_DoesNotFaultParentRestoreAgentChatResumeTestsAgentChat_Resume_SubAgentMaterializationFails_StillResolvesToTerminalStateAgentChatResumeTestsRunning(reconciles with #1128).RestoreSubAgentsAsync_SubAgentMaterializationFails_TerminalTaskObservedNotDroppedAgentChatHostedSubAgentTestsTask.AgentChat_HostedSubAgent_PersistThenReload_RestoresAssistantTextAndToolCallsAgentChatHostedSubAgentTestsHistory— asserting content, not justHistory.Count > 0.