@@ -370,6 +370,60 @@ void getLLMConversationThreadPrunesAutomationSubtrees() {
370370 assertEquals (choice , thread .get (1 ));
371371 }
372372
373+ @ Test
374+ void getLLMConversationThreadWorksWithoutRootSpan () {
375+ // Trace scorers run mid-flight: spans close bottom-up, so the parent/root span may not
376+ // yet be ingested. Here the task span's parent ("root") is absent from the fetched set,
377+ // making the task span a forest root. The thread must still be reconstructed.
378+ var sysMsg = Map .<String , Object >of ("role" , "system" , "content" , "be helpful" );
379+ var userMsg = Map .<String , Object >of ("role" , "user" , "content" , "strawberry" );
380+ var assistantMsg = Map .<String , Object >of ("role" , "assistant" , "content" , "fruit" );
381+ var choice =
382+ Map .<String , Object >of (
383+ "finish_reason" , "stop" , "index" , 0 , "message" , assistantMsg );
384+
385+ // task span points at a "root" parent that was NOT fetched (root not ended/ingested yet)
386+ var task = taskSpan ("task" , "root" , 1.0 );
387+ var llm = llmSpan ("llm1" , "task" , 1.1 , List .of (sysMsg , userMsg ), List .of (choice ));
388+
389+ var trace = traceWithSpans (List .of (task , llm )); // no root span present
390+ var thread = trace .getLLMConversationThread ();
391+
392+ assertEquals (3 , thread .size (), "thread must be reconstructed even without the root span" );
393+ assertEquals ("system" , thread .get (0 ).get ("role" ));
394+ assertEquals ("user" , thread .get (1 ).get ("role" ));
395+ assertEquals (assistantMsg , thread .get (2 ).get ("message" ));
396+ }
397+
398+ @ Test
399+ void getLLMConversationThreadHandlesMultipleOrphanSubtrees () {
400+ // Multiple orphan subtrees whose common ancestor ("root") is absent. Each subtree is a
401+ // forest root; they must be visited in start-time order and their messages concatenated.
402+ var user1 = Map .<String , Object >of ("role" , "user" , "content" , "Q1" );
403+ var asst1 = Map .<String , Object >of ("role" , "assistant" , "content" , "A1" );
404+ var choice1 = Map .<String , Object >of ("finish_reason" , "stop" , "message" , asst1 );
405+
406+ var user2 = Map .<String , Object >of ("role" , "user" , "content" , "Q2" );
407+ var asst2 = Map .<String , Object >of ("role" , "assistant" , "content" , "A2" );
408+ var choice2 = Map .<String , Object >of ("finish_reason" , "stop" , "message" , asst2 );
409+
410+ // Two task subtrees both parented at a missing "root". Provide out of order; turn2 starts
411+ // later than turn1, so turn1's messages must come first.
412+ var turn2 = taskSpan ("turn2" , "root" , 2.0 );
413+ var llm2 = llmSpan ("llm2" , "turn2" , 2.1 , List .of (user2 ), List .of (choice2 ));
414+ var turn1 = taskSpan ("turn1" , "root" , 1.0 );
415+ var llm1 = llmSpan ("llm1" , "turn1" , 1.1 , List .of (user1 ), List .of (choice1 ));
416+
417+ var trace = traceWithSpans (List .of (turn2 , llm2 , turn1 , llm1 )); // root absent, out of order
418+ var thread = trace .getLLMConversationThread ();
419+
420+ assertEquals (4 , thread .size ());
421+ assertEquals ("Q1" , thread .get (0 ).get ("content" ));
422+ assertEquals (asst1 , thread .get (1 ).get ("message" ));
423+ assertEquals ("Q2" , thread .get (2 ).get ("content" ));
424+ assertEquals (asst2 , thread .get (3 ).get ("message" ));
425+ }
426+
373427 // -------------------------------------------------------------------------
374428 // fetchWithRetry — retry logic (via package-private constructor with custom supplier)
375429 // -------------------------------------------------------------------------
0 commit comments