@@ -1181,6 +1181,14 @@ describe("BasicAgent", () => {
11811181 expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
11821182 expect ( reasoningEndIdx ) . toBeLessThan ( toolCallStartIdx ) ;
11831183
1184+ // Each close event must appear exactly once (guard against double-emit)
1185+ expect (
1186+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1187+ ) . toHaveLength ( 1 ) ;
1188+ expect (
1189+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1190+ ) . toHaveLength ( 1 ) ;
1191+
11841192 // Stream still completes with RUN_FINISHED
11851193 expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
11861194 } ) ;
@@ -1223,6 +1231,14 @@ describe("BasicAgent", () => {
12231231 expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
12241232 expect ( reasoningEndIdx ) . toBeLessThan ( textChunkIdx ) ;
12251233
1234+ // Each close event must appear exactly once (guard against double-emit)
1235+ expect (
1236+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1237+ ) . toHaveLength ( 1 ) ;
1238+ expect (
1239+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1240+ ) . toHaveLength ( 1 ) ;
1241+
12261242 // Stream still completes with RUN_FINISHED
12271243 expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
12281244 } ) ;
@@ -1261,6 +1277,14 @@ describe("BasicAgent", () => {
12611277 expect ( reasoningMsgEndIdx ) . toBeGreaterThan ( 0 ) ;
12621278 expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
12631279
1280+ // Each close event must appear exactly once (guard against double-emit)
1281+ expect (
1282+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1283+ ) . toHaveLength ( 1 ) ;
1284+ expect (
1285+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1286+ ) . toHaveLength ( 1 ) ;
1287+
12641288 // Stream still completes with RUN_FINISHED
12651289 expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
12661290 } ) ;
@@ -1300,6 +1324,17 @@ describe("BasicAgent", () => {
13001324 expect ( reasoningMsgEndIdx ) . toBeGreaterThan ( 0 ) ;
13011325 expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
13021326 expect ( runFinishedIdx ) . toBeGreaterThan ( reasoningEndIdx ) ;
1327+
1328+ // Each close event must appear exactly once (guard against double-emit)
1329+ expect (
1330+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1331+ ) . toHaveLength ( 1 ) ;
1332+ expect (
1333+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1334+ ) . toHaveLength ( 1 ) ;
1335+
1336+ // Stream still completes with RUN_FINISHED
1337+ expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
13031338 } ) ;
13041339
13051340 it ( "should auto-close reasoning when stream errors mid-reasoning" , async ( ) => {
@@ -1346,6 +1381,170 @@ describe("BasicAgent", () => {
13461381 expect ( reasoningMsgEndIdx ) . toBeGreaterThan ( 0 ) ;
13471382 expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
13481383 expect ( runErrorIdx ) . toBeGreaterThan ( reasoningEndIdx ) ;
1384+
1385+ // Each close event must appear exactly once (guard against double-emit)
1386+ expect (
1387+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1388+ ) . toHaveLength ( 1 ) ;
1389+ expect (
1390+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1391+ ) . toHaveLength ( 1 ) ;
1392+ } ) ;
1393+
1394+ it ( "should auto-close reasoning for consecutive blocks with no reasoning-end between them" , async ( ) => {
1395+ const agent = new BasicAgent ( {
1396+ model : "openai/gpt-4o" ,
1397+ } ) ;
1398+
1399+ vi . mocked ( streamText ) . mockReturnValue (
1400+ mockStreamTextResponse ( [
1401+ reasoningStart ( ) ,
1402+ reasoningDelta ( "First thought" ) ,
1403+ // NO reasoningEnd() — second block starts immediately
1404+ reasoningStart ( ) ,
1405+ reasoningDelta ( "Second thought" ) ,
1406+ reasoningEnd ( ) ,
1407+ finish ( ) ,
1408+ ] ) as any ,
1409+ ) ;
1410+
1411+ const input : RunAgentInput = {
1412+ threadId : "thread1" ,
1413+ runId : "run1" ,
1414+ messages : [ ] ,
1415+ tools : [ ] ,
1416+ context : [ ] ,
1417+ state : { } ,
1418+ } ;
1419+
1420+ const events = await collectEvents ( agent [ "run" ] ( input ) ) ;
1421+ const eventTypes = events . map ( ( e : any ) => e . type ) ;
1422+
1423+ // Both reasoning blocks must be properly closed — two complete lifecycles
1424+ expect (
1425+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1426+ ) . toHaveLength ( 2 ) ;
1427+ expect (
1428+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1429+ ) . toHaveLength ( 2 ) ;
1430+
1431+ // First block's REASONING_END must appear before second block's REASONING_START
1432+ const firstReasoningEndIdx = eventTypes . indexOf ( EventType . REASONING_END ) ;
1433+ const secondReasoningStartIdx = eventTypes . lastIndexOf (
1434+ EventType . REASONING_START ,
1435+ ) ;
1436+ expect ( firstReasoningEndIdx ) . toBeLessThan ( secondReasoningStartIdx ) ;
1437+
1438+ // The two blocks must use distinct messageIds
1439+ const startEvents = events . filter (
1440+ ( e : any ) => e . type === EventType . REASONING_START ,
1441+ ) ;
1442+ expect ( startEvents ) . toHaveLength ( 2 ) ;
1443+ expect ( ( startEvents [ 0 ] as any ) . messageId ) . not . toBe (
1444+ ( startEvents [ 1 ] as any ) . messageId ,
1445+ ) ;
1446+
1447+ expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
1448+ } ) ;
1449+
1450+ it ( "should close reasoning when an exception is thrown mid-stream" , async ( ) => {
1451+ const agent = new BasicAgent ( {
1452+ model : "openai/gpt-4o" ,
1453+ } ) ;
1454+
1455+ // Simulate the fullStream generator throwing mid-iteration (not a stream error event)
1456+ const throwingStream = {
1457+ fullStream : ( async function * ( ) {
1458+ yield reasoningStart ( ) ;
1459+ yield reasoningDelta ( "Thinking..." ) ;
1460+ throw new Error ( "unexpected network failure" ) ;
1461+ } ) ( ) ,
1462+ } ;
1463+ vi . mocked ( streamText ) . mockReturnValue ( throwingStream as any ) ;
1464+
1465+ const input : RunAgentInput = {
1466+ threadId : "thread1" ,
1467+ runId : "run1" ,
1468+ messages : [ ] ,
1469+ tools : [ ] ,
1470+ context : [ ] ,
1471+ state : { } ,
1472+ } ;
1473+
1474+ // subscriber.error() causes collectEvents to reject, so collect manually
1475+ const events : any [ ] = [ ] ;
1476+ await new Promise < void > ( ( resolve ) => {
1477+ agent [ "run" ] ( input ) . subscribe ( {
1478+ next : ( e : any ) => events . push ( e ) ,
1479+ error : ( ) => resolve ( ) , // error is expected
1480+ complete : ( ) => resolve ( ) ,
1481+ } ) ;
1482+ } ) ;
1483+
1484+ const eventTypes = events . map ( ( e : any ) => e . type ) ;
1485+
1486+ // Reasoning must be closed before RUN_ERROR despite the exception path
1487+ const reasoningMsgEndIdx = eventTypes . indexOf (
1488+ EventType . REASONING_MESSAGE_END ,
1489+ ) ;
1490+ const reasoningEndIdx = eventTypes . indexOf ( EventType . REASONING_END ) ;
1491+ const runErrorIdx = eventTypes . indexOf ( EventType . RUN_ERROR ) ;
1492+ expect ( reasoningMsgEndIdx ) . toBeGreaterThan ( 0 ) ;
1493+ expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
1494+ expect ( runErrorIdx ) . toBeGreaterThan ( reasoningEndIdx ) ;
1495+
1496+ expect (
1497+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1498+ ) . toHaveLength ( 1 ) ;
1499+ expect (
1500+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1501+ ) . toHaveLength ( 1 ) ;
1502+ } ) ;
1503+
1504+ it ( "should close reasoning and emit RUN_FINISHED when stream exhausts without terminal event" , async ( ) => {
1505+ const agent = new BasicAgent ( {
1506+ model : "openai/gpt-4o" ,
1507+ } ) ;
1508+
1509+ // Stream ends with no finish/abort/error — exercises !terminalEventEmitted fallback
1510+ vi . mocked ( streamText ) . mockReturnValue (
1511+ mockStreamTextResponse ( [
1512+ reasoningStart ( ) ,
1513+ reasoningDelta ( "Thinking..." ) ,
1514+ // deliberate: no finish(), no abort(), no error()
1515+ ] ) as any ,
1516+ ) ;
1517+
1518+ const input : RunAgentInput = {
1519+ threadId : "thread1" ,
1520+ runId : "run1" ,
1521+ messages : [ ] ,
1522+ tools : [ ] ,
1523+ context : [ ] ,
1524+ state : { } ,
1525+ } ;
1526+
1527+ const events = await collectEvents ( agent [ "run" ] ( input ) ) ;
1528+ const eventTypes = events . map ( ( e : any ) => e . type ) ;
1529+
1530+ // Reasoning must be closed before RUN_FINISHED via fallback
1531+ const reasoningMsgEndIdx = eventTypes . indexOf (
1532+ EventType . REASONING_MESSAGE_END ,
1533+ ) ;
1534+ const reasoningEndIdx = eventTypes . indexOf ( EventType . REASONING_END ) ;
1535+ const runFinishedIdx = eventTypes . indexOf ( EventType . RUN_FINISHED ) ;
1536+ expect ( reasoningMsgEndIdx ) . toBeGreaterThan ( 0 ) ;
1537+ expect ( reasoningEndIdx ) . toBeGreaterThan ( reasoningMsgEndIdx ) ;
1538+ expect ( runFinishedIdx ) . toBeGreaterThan ( reasoningEndIdx ) ;
1539+
1540+ expect (
1541+ eventTypes . filter ( ( t ) => t === EventType . REASONING_MESSAGE_END ) ,
1542+ ) . toHaveLength ( 1 ) ;
1543+ expect (
1544+ eventTypes . filter ( ( t ) => t === EventType . REASONING_END ) ,
1545+ ) . toHaveLength ( 1 ) ;
1546+
1547+ expect ( eventTypes [ eventTypes . length - 1 ] ) . toBe ( EventType . RUN_FINISHED ) ;
13491548 } ) ;
13501549
13511550 it ( "should handle reasoning interleaved with tool calls" , async ( ) => {
0 commit comments