@@ -653,6 +653,196 @@ internal class WorkflowRuntimeMonitorTest {
653653 assertTrue(renderPassTracker.renderPassInfoReceived!! .renderCauseis RenderCause .Callback )
654654 }
655655
656+ @Test
657+ fun `onSessionCancelled logs dropped actions` () {
658+ val runtimeListener= TestWorkflowRuntimeLoopListener ()
659+ val monitor= WorkflowRuntimeMonitor (
660+ runtimeName= runtimeName,
661+ workflowRuntimeTracers= listOf (fakeRuntimeTracer),
662+ runtimeLoopListener= runtimeListener
663+ )
664+ val testWorkflow= TestWorkflow ()
665+ val rootSession= testWorkflow.createRootSession()
666+ val testScope= TestScope ()
667+
668+ // Initialize session
669+ monitor.onSessionStarted(testScope, rootSession)
670+
671+ // Create some test actions
672+ val action1= TestAction (" action1" )
673+ val action2= TestAction (" action2" )
674+ val droppedActions= listOf (action1, action2)
675+
676+ // Call onSessionCancelled with dropped actions
677+ monitor.onSessionCancelled(
678+ cause= null ,
679+ droppedActions= droppedActions,
680+ session= rootSession
681+ )
682+
683+ // Settle the runtime to flush updates
684+ monitor.onRuntimeUpdate(RuntimeSettled )
685+
686+ // Verify dropped actions were logged
687+ val updates= runtimeListener.runtimeUpdatesReceived!! .readAndClear()
688+ val droppedLogLines= updates.filterIsInstance<ActionDroppedLogLine >()
689+
690+ assertEquals(2 , droppedLogLines.size)
691+ assertEquals(" action1" , droppedLogLines[0 ].actionName)
692+ assertEquals(" action2" , droppedLogLines[1 ].actionName)
693+ }
694+
695+ @Test
696+ fun `onSessionCancelled logs no actions when list is empty` () {
697+ val runtimeListener= TestWorkflowRuntimeLoopListener ()
698+ val monitor= WorkflowRuntimeMonitor (
699+ runtimeName= runtimeName,
700+ workflowRuntimeTracers= listOf (fakeRuntimeTracer),
701+ runtimeLoopListener= runtimeListener
702+ )
703+ val testWorkflow= TestWorkflow ()
704+ val rootSession= testWorkflow.createRootSession()
705+ val testScope= TestScope ()
706+
707+ // Initialize session
708+ monitor.onSessionStarted(testScope, rootSession)
709+
710+ // Call onSessionCancelled with empty dropped actions list
711+ monitor.onSessionCancelled(
712+ cause= null ,
713+ droppedActions= emptyList<WorkflowAction <String ,String ,String >>(),
714+ session= rootSession
715+ )
716+
717+ // Settle the runtime to flush updates
718+ monitor.onRuntimeUpdate(RuntimeSettled )
719+
720+ // Verify no dropped actions were logged
721+ val updates= runtimeListener.runtimeUpdatesReceived!! .readAndClear()
722+ val droppedLogLines= updates.filterIsInstance<ActionDroppedLogLine >()
723+
724+ assertEquals(0 , droppedLogLines.size)
725+ }
726+
727+ @Test
728+ fun `onSessionCancelled calls tracer onWorkflowSessionStopped` () {
729+ val monitor= WorkflowRuntimeMonitor (
730+ runtimeName= runtimeName,
731+ workflowRuntimeTracers= listOf (fakeRuntimeTracer)
732+ )
733+ val testWorkflow= TestWorkflow ()
734+ val rootSession= testWorkflow.createRootSession()
735+ val testScope= TestScope ()
736+
737+ // Initialize session
738+ monitor.onSessionStarted(testScope, rootSession)
739+
740+ // Verify session is tracked
741+ assertEquals(1 , monitor.workflowSessionInfo.size)
742+
743+ // Call onSessionCancelled
744+ monitor.onSessionCancelled(
745+ cause= null ,
746+ droppedActions= emptyList<WorkflowAction <String ,String ,String >>(),
747+ session= rootSession
748+ )
749+
750+ // Verify session was removed from tracking
751+ assertEquals(0 , monitor.workflowSessionInfo.size)
752+ assertTrue(fakeRuntimeTracer.onWorkflowSessionStoppedCalled)
753+ }
754+
755+ @Test
756+ fun `onSessionCancelled with CancellationException logs dropped actions` () {
757+ val runtimeListener= TestWorkflowRuntimeLoopListener ()
758+ val monitor= WorkflowRuntimeMonitor (
759+ runtimeName= runtimeName,
760+ workflowRuntimeTracers= listOf (fakeRuntimeTracer),
761+ runtimeLoopListener= runtimeListener
762+ )
763+ val testWorkflow= TestWorkflow ()
764+ val rootSession= testWorkflow.createRootSession()
765+ val testScope= TestScope ()
766+
767+ // Initialize session
768+ monitor.onSessionStarted(testScope, rootSession)
769+
770+ // Create test actions
771+ val action1= TestAction (" cancelledAction" )
772+ val droppedActions= listOf (action1)
773+
774+ // Call onSessionCancelled with a CancellationException
775+ val cancellationException= kotlinx.coroutines.CancellationException (" Test cancellation" )
776+ monitor.onSessionCancelled(
777+ cause= cancellationException,
778+ droppedActions= droppedActions,
779+ session= rootSession
780+ )
781+
782+ // Settle the runtime to flush updates
783+ monitor.onRuntimeUpdate(RuntimeSettled )
784+
785+ // Verify dropped actions were logged even with cancellation exception
786+ val updates= runtimeListener.runtimeUpdatesReceived!! .readAndClear()
787+ val droppedLogLines= updates.filterIsInstance<ActionDroppedLogLine >()
788+
789+ assertEquals(1 , droppedLogLines.size)
790+ assertEquals(" cancelledAction" , droppedLogLines[0 ].actionName)
791+ }
792+
793+ @Test
794+ fun `ActionDroppedLogLine formats correctly in log output` () {
795+ val actionName= " testAction"
796+ val logLine= ActionDroppedLogLine (actionName)
797+ val builder= StringBuilder ()
798+
799+ logLine.log(builder)
800+
801+ val expected= " DROPPED:$actionName \n "
802+ assertEquals(expected, builder.toString())
803+ }
804+
805+ @Test
806+ fun `onSessionCancelled with multiple actions logs all in order` () {
807+ val runtimeListener= TestWorkflowRuntimeLoopListener ()
808+ val monitor= WorkflowRuntimeMonitor (
809+ runtimeName= runtimeName,
810+ runtimeLoopListener= runtimeListener
811+ )
812+ val testWorkflow= TestWorkflow ()
813+ val rootSession= testWorkflow.createRootSession()
814+ val testScope= TestScope ()
815+
816+ // Initialize session
817+ monitor.onSessionStarted(testScope, rootSession)
818+
819+ // Create multiple test actions with distinct names
820+ val actions= listOf (
821+ TestAction (" firstAction" ),
822+ TestAction (" secondAction" ),
823+ TestAction (" thirdAction" )
824+ )
825+
826+ // Call onSessionCancelled with multiple dropped actions
827+ monitor.onSessionCancelled(
828+ cause= null ,
829+ droppedActions= actions,
830+ session= rootSession
831+ )
832+
833+ // Settle the runtime to flush updates
834+ monitor.onRuntimeUpdate(RuntimeSettled )
835+
836+ // Verify all dropped actions were logged in order
837+ val updates= runtimeListener.runtimeUpdatesReceived!! .readAndClear()
838+ val droppedLogLines= updates.filterIsInstance<ActionDroppedLogLine >()
839+
840+ assertEquals(3 , droppedLogLines.size)
841+ assertEquals(" firstAction" , droppedLogLines[0 ].actionName)
842+ assertEquals(" secondAction" , droppedLogLines[1 ].actionName)
843+ assertEquals(" thirdAction" , droppedLogLines[2 ].actionName)
844+ }
845+
656846// Test helper classes
657847private class TestWorkflow :StatefulWorkflow <String ,String ,String ,String >() {
658848override fun initialState (
@@ -749,6 +939,14 @@ internal class WorkflowRuntimeMonitorTest {
749939 }
750940 }
751941
942+ private class TestAction (name : String ) : WorkflowAction<String, String, String>() {
943+ override fun Updater.apply () {
944+ // No-op for testing
945+ }
946+
947+ override val debuggingName: String = name
948+ }
949+
752950private class TestWorkflowRuntimeTracer :WorkflowRuntimeTracer () {
753951var onWorkflowSessionStartedCalled= false
754952var onWorkflowSessionStartedCallCount= 0