@@ -9,8 +9,10 @@ public sealed class AgentFrameworkRuntimeClientTests
99 private const string PlanPrompt = "Plan the embedded runtime rollout." ;
1010 private const string ApprovedResumeSummary = "Approved by the operator." ;
1111 private const string RejectedResumeSummary = "Rejected by the operator." ;
12+ private const string ResumeRejectedKind = "approval-rejected" ;
1213 private const string ArchiveFileName = "archive.json" ;
1314 private const string ReplayFileName = "replay.md" ;
15+ private static readonly DateTimeOffset FixedTimestamp = new ( 2026 , 3 , 14 , 9 , 30 , 0 , TimeSpan . Zero ) ;
1416
1517 [ Test ]
1618 public async Task ExecuteAsyncPersistsAReplayArchiveForPlanMode ( )
@@ -96,9 +98,41 @@ public async Task ResumeAsyncPersistsRejectedApprovalAsFailedReplay()
9698 rejectedResult . Value . ApprovalState . Should ( ) . Be ( ApprovalState . Rejected ) ;
9799 archiveResult . IsSuccess . Should ( ) . BeTrue ( ) ;
98100 archiveResult . Value ! . Phase . Should ( ) . Be ( SessionPhase . Failed ) ;
101+ archiveResult . Value . Replay . Should ( ) . Contain ( entry => entry . Kind == ResumeRejectedKind && entry . Phase == SessionPhase . Failed ) ;
99102 archiveResult . Value . Replay . Should ( ) . Contain ( entry => entry . Kind == "run-completed" && entry . Phase == SessionPhase . Failed ) ;
100103 }
101104
105+ [ Test ]
106+ public async Task ResumeAsyncRejectsArchivedSessionsThatAreNoLongerPausedForApproval ( )
107+ {
108+ using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory ( ) ;
109+ var request = CreateRequest ( ApprovalPrompt , AgentExecutionMode . Execute ) ;
110+
111+ {
112+ using var firstHost = CreateHost ( runtimeDirectory . Root ) ;
113+ await firstHost . StartAsync ( ) ;
114+ var firstClient = firstHost . Services . GetRequiredService < IAgentRuntimeClient > ( ) ;
115+ _ = await firstClient . ExecuteAsync ( request , CancellationToken . None ) ;
116+ _ = await firstClient . ResumeAsync (
117+ new AgentTurnResumeRequest ( request . SessionId , ApprovalState . Approved , ApprovedResumeSummary ) ,
118+ CancellationToken . None ) ;
119+ }
120+
121+ {
122+ using var secondHost = CreateHost ( runtimeDirectory . Root ) ;
123+ await secondHost . StartAsync ( ) ;
124+ var secondClient = secondHost . Services . GetRequiredService < IAgentRuntimeClient > ( ) ;
125+
126+ var result = await secondClient . ResumeAsync (
127+ new AgentTurnResumeRequest ( request . SessionId , ApprovalState . Approved , ApprovedResumeSummary ) ,
128+ CancellationToken . None ) ;
129+
130+ result . IsFailed . Should ( ) . BeTrue ( ) ;
131+ result . Problem ! . HasErrorCode ( RuntimeCommunicationProblemCode . ResumeCheckpointMissing ) . Should ( ) . BeTrue ( ) ;
132+ result . Problem . Detail . Should ( ) . Contain ( "cannot be resumed" ) ;
133+ }
134+ }
135+
102136 [ Test ]
103137 public async Task GetSessionArchiveAsyncReturnsMissingProblemWhenNothingWasPersisted ( )
104138 {
@@ -133,6 +167,52 @@ public async Task GetSessionArchiveAsyncReturnsCorruptionProblemForInvalidArchiv
133167 result . Problem ! . HasErrorCode ( RuntimeCommunicationProblemCode . SessionArchiveCorrupted ) . Should ( ) . BeTrue ( ) ;
134168 }
135169
170+ [ Test ]
171+ public async Task ResumeAsyncReturnsCorruptionProblemForInvalidArchivePayload ( )
172+ {
173+ using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory ( ) ;
174+ var sessionId = SessionId . New ( ) ;
175+ var sessionDirectory = Path . Combine ( runtimeDirectory . Root , sessionId . ToString ( ) ) ;
176+ Directory . CreateDirectory ( sessionDirectory ) ;
177+ await File . WriteAllTextAsync ( Path . Combine ( sessionDirectory , ArchiveFileName ) , "{ invalid json" , CancellationToken . None ) ;
178+
179+ using var host = CreateHost ( runtimeDirectory . Root ) ;
180+ await host . StartAsync ( ) ;
181+ var client = host . Services . GetRequiredService < IAgentRuntimeClient > ( ) ;
182+
183+ var result = await client . ResumeAsync (
184+ new AgentTurnResumeRequest ( sessionId , ApprovalState . Approved , ApprovedResumeSummary ) ,
185+ CancellationToken . None ) ;
186+
187+ result . IsFailed . Should ( ) . BeTrue ( ) ;
188+ result . Problem ! . HasErrorCode ( RuntimeCommunicationProblemCode . SessionArchiveCorrupted ) . Should ( ) . BeTrue ( ) ;
189+ }
190+
191+ [ Test ]
192+ public async Task AgentFrameworkRuntimeClientUsesTheInjectedTimeProviderForReplayArchiveAndSessionTimestamps ( )
193+ {
194+ using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory ( ) ;
195+ using var host = CreateHost ( runtimeDirectory . Root ) ;
196+ await host . StartAsync ( ) ;
197+ var client = CreateClient ( host . Services , runtimeDirectory . Root , new FixedTimeProvider ( FixedTimestamp ) ) ;
198+ var request = CreateRequest ( PlanPrompt , AgentExecutionMode . Plan ) ;
199+
200+ var result = await client . ExecuteAsync ( request , CancellationToken . None ) ;
201+ var archiveResult = await client . GetSessionArchiveAsync ( request . SessionId , CancellationToken . None ) ;
202+ var session = await host . Services
203+ . GetRequiredService < IGrainFactory > ( )
204+ . GetGrain < ISessionGrain > ( request . SessionId . ToString ( ) )
205+ . GetAsync ( ) ;
206+
207+ result . IsSuccess . Should ( ) . BeTrue ( ) ;
208+ archiveResult . IsSuccess . Should ( ) . BeTrue ( ) ;
209+ archiveResult . Value ! . UpdatedAt . Should ( ) . Be ( FixedTimestamp ) ;
210+ archiveResult . Value . Replay . Should ( ) . OnlyContain ( entry => entry . RecordedAt == FixedTimestamp ) ;
211+ session . Should ( ) . NotBeNull ( ) ;
212+ session ! . CreatedAt . Should ( ) . Be ( FixedTimestamp ) ;
213+ session . UpdatedAt . Should ( ) . Be ( FixedTimestamp ) ;
214+ }
215+
136216 [ Test ]
137217 public async Task ExtractCheckpointReturnsNullWhenRunHasNoCheckpointData ( )
138218 {
@@ -212,6 +292,24 @@ private static AgentTurnRequest CreateRequest(string prompt, AgentExecutionMode
212292 return new AgentTurnRequest ( SessionId . New ( ) , AgentProfileId . New ( ) , prompt , mode , ProviderConnectionStatus . Available ) ;
213293 }
214294
295+ private static AgentFrameworkRuntimeClient CreateClient ( IServiceProvider services , string rootDirectory , TimeProvider timeProvider )
296+ {
297+ return ( AgentFrameworkRuntimeClient ) Activator . CreateInstance (
298+ typeof ( AgentFrameworkRuntimeClient ) ,
299+ BindingFlags . Instance | BindingFlags . NonPublic ,
300+ binder : null ,
301+ args :
302+ [
303+ services . GetRequiredService < IGrainFactory > ( ) ,
304+ new RuntimeSessionArchiveStore ( new RuntimePersistenceOptions
305+ {
306+ RootDirectoryPath = rootDirectory ,
307+ } ) ,
308+ timeProvider ,
309+ ] ,
310+ culture : null ) ! ;
311+ }
312+
215313 private static Microsoft . Agents . AI . Workflows . Workflow CreateNoCheckpointWorkflow ( )
216314 {
217315 var executor = new Microsoft . Agents . AI . Workflows . FunctionExecutor < string > (
@@ -248,6 +346,11 @@ private static int GetFreeTcpPort()
248346 }
249347}
250348
349+ internal sealed class FixedTimeProvider ( DateTimeOffset timestamp ) : TimeProvider
350+ {
351+ public override DateTimeOffset GetUtcNow ( ) => timestamp ;
352+ }
353+
251354internal sealed class TemporaryRuntimePersistenceDirectory : IDisposable
252355{
253356 public TemporaryRuntimePersistenceDirectory ( )
0 commit comments