@@ -1399,6 +1399,239 @@ describe("createReactor — doom-loop detection", () => {
13991399 expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
14001400 } ) ;
14011401
1402+ test ( "trips on trivial run_shell churn with fresh echo strings" , async ( ) => {
1403+ // A runaway model evading the guard by varying a no-op payload: every
1404+ // turn runs `echo` with a brand-new string, so argument-level identity
1405+ // never repeats. Each turn is still the same trivial shell no-op, so the
1406+ // default threshold of 3 must trip.
1407+ const { reactor, events, waitFor } = createTestReactor ( {
1408+ director : createBatchLoopDirector ( ( turn ) =>
1409+ turn < 8
1410+ ? [
1411+ {
1412+ id : `c${ turn } ` ,
1413+ name : "run_shell" ,
1414+ arguments : { command : `echo churn-${ turn } ` } ,
1415+ } ,
1416+ ]
1417+ : null ,
1418+ ) ,
1419+ } ) ;
1420+
1421+ reactor . start ( ) ;
1422+ reactor . deliver ( makeInboundMessage ( ) ) ;
1423+ await waitFor ( "reactor.done" ) ;
1424+
1425+ const error = getEvent ( events , "reactor.error" ) ;
1426+ expect ( error . data . fatal ) . toBe ( true ) ;
1427+ expect ( error . data . error ) . toContain ( "run_shell" ) ;
1428+ expect ( getEvent ( events , "message.run.ended" ) . data . error ?. kind ) . toBe (
1429+ "doom_loop" ,
1430+ ) ;
1431+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 3 ) ;
1432+ } ) ;
1433+
1434+ test ( "collapses echo churn even when cwd and timeout differ" , async ( ) => {
1435+ // Extra fields drop from the no-op marker so they collapse among no-ops,
1436+ // but the marker has no `arguments` key so it cannot share identity with
1437+ // a real run_shell call that happens to carry the same cwd/timeout.
1438+ const { reactor, events, waitFor } = createTestReactor ( {
1439+ director : createBatchLoopDirector ( ( turn ) =>
1440+ turn < 8
1441+ ? [
1442+ {
1443+ id : `c${ turn } ` ,
1444+ name : "run_shell" ,
1445+ arguments : {
1446+ command : `echo churn-${ turn } ` ,
1447+ cwd : `/tmp/cwd-${ turn } ` ,
1448+ timeout : 1000 + turn ,
1449+ } ,
1450+ } ,
1451+ ]
1452+ : null ,
1453+ ) ,
1454+ } ) ;
1455+
1456+ reactor . start ( ) ;
1457+ reactor . deliver ( makeInboundMessage ( ) ) ;
1458+ await waitFor ( "reactor.done" ) ;
1459+
1460+ expect ( getEvent ( events , "reactor.error" ) . data . fatal ) . toBe ( true ) ;
1461+ expect ( getEvent ( events , "message.run.ended" ) . data . error ?. kind ) . toBe (
1462+ "doom_loop" ,
1463+ ) ;
1464+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 3 ) ;
1465+ } ) ;
1466+
1467+ test ( "trips on mixed echo/true trivial churn" , async ( ) => {
1468+ // Alternating between the two no-op programs with fresh arguments must
1469+ // count as the same loop, not as distinct work.
1470+ const commands = [
1471+ "echo alpha" ,
1472+ "true alpha" ,
1473+ "echo bravo" ,
1474+ "true bravo" ,
1475+ "echo charlie" ,
1476+ "true charlie" ,
1477+ "echo delta" ,
1478+ "true delta" ,
1479+ ] ;
1480+ const { reactor, events, waitFor } = createTestReactor ( {
1481+ director : createBatchLoopDirector ( ( turn ) => {
1482+ const command = commands [ turn ] ;
1483+ return command === undefined
1484+ ? null
1485+ : [ { id : `c${ turn } ` , name : "run_shell" , arguments : { command } } ] ;
1486+ } ) ,
1487+ } ) ;
1488+
1489+ reactor . start ( ) ;
1490+ reactor . deliver ( makeInboundMessage ( ) ) ;
1491+ await waitFor ( "reactor.done" ) ;
1492+
1493+ expect ( getEvent ( events , "reactor.error" ) . data . fatal ) . toBe ( true ) ;
1494+ expect ( getEvent ( events , "message.run.ended" ) . data . error ?. kind ) . toBe (
1495+ "doom_loop" ,
1496+ ) ;
1497+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 3 ) ;
1498+ } ) ;
1499+
1500+ test ( "does not trip on distinct legitimate shell work" , async ( ) => {
1501+ // Different real commands are different turns: a changing command string
1502+ // here is genuine work, not no-op churn, so five distinct commands must
1503+ // run to completion.
1504+ const commands = [
1505+ "git status" ,
1506+ "npm test" ,
1507+ "git diff --stat" ,
1508+ "npm run build" ,
1509+ "git log --oneline -3" ,
1510+ ] ;
1511+ const { reactor, events, waitFor } = createTestReactor ( {
1512+ director : createBatchLoopDirector ( ( turn ) => {
1513+ const command = commands [ turn ] ;
1514+ return command === undefined
1515+ ? null
1516+ : [ { id : `c${ turn } ` , name : "run_shell" , arguments : { command } } ] ;
1517+ } ) ,
1518+ } ) ;
1519+
1520+ reactor . start ( ) ;
1521+ reactor . deliver ( makeInboundMessage ( ) ) ;
1522+ await waitFor ( "reactor.done" ) ;
1523+
1524+ expect ( events . some ( ( e ) => e . type === "reactor.error" ) ) . toBe ( false ) ;
1525+ expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
1526+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 5 ) ;
1527+ } ) ;
1528+
1529+ test ( "does not collapse echo with redirection into trivial churn" , async ( ) => {
1530+ // `echo payload > file` writes a file: a side effect, not a no-op. Fresh
1531+ // redirect targets must keep distinct signatures so real file writes are
1532+ // never mistaken for churn.
1533+ const { reactor, events, waitFor } = createTestReactor ( {
1534+ director : createBatchLoopDirector ( ( turn ) =>
1535+ turn < 5
1536+ ? [
1537+ {
1538+ id : `c${ turn } ` ,
1539+ name : "run_shell" ,
1540+ arguments : { command : `echo payload-${ turn } > out-${ turn } .txt` } ,
1541+ } ,
1542+ ]
1543+ : null ,
1544+ ) ,
1545+ } ) ;
1546+
1547+ reactor . start ( ) ;
1548+ reactor . deliver ( makeInboundMessage ( ) ) ;
1549+ await waitFor ( "reactor.done" ) ;
1550+
1551+ expect ( events . some ( ( e ) => e . type === "reactor.error" ) ) . toBe ( false ) ;
1552+ expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
1553+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 5 ) ;
1554+ } ) ;
1555+
1556+ test ( "does not collapse a real trivial-noop-shell command into echo churn" , async ( ) => {
1557+ // The no-op marker must be disjoint from any legal command string. If it
1558+ // reused `trivial-noop-shell` as a command, two echo no-ops plus one real
1559+ // `trivial-noop-shell` would trip the default threshold of 3.
1560+ const commands = [
1561+ "echo a" ,
1562+ "echo b" ,
1563+ "trivial-noop-shell" ,
1564+ "echo c" ,
1565+ "echo d" ,
1566+ ] ;
1567+ const { reactor, events, waitFor } = createTestReactor ( {
1568+ director : createBatchLoopDirector ( ( turn ) => {
1569+ const command = commands [ turn ] ;
1570+ return command === undefined
1571+ ? null
1572+ : [ { id : `c${ turn } ` , name : "run_shell" , arguments : { command } } ] ;
1573+ } ) ,
1574+ } ) ;
1575+
1576+ reactor . start ( ) ;
1577+ reactor . deliver ( makeInboundMessage ( ) ) ;
1578+ await waitFor ( "reactor.done" ) ;
1579+
1580+ expect ( events . some ( ( e ) => e . type === "reactor.error" ) ) . toBe ( false ) ;
1581+ expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
1582+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 5 ) ;
1583+ } ) ;
1584+
1585+ test ( "does not collapse relative-path true into trivial churn" , async ( ) => {
1586+ // Exact program tokens only: `./true` is not `true`. Basename matching
1587+ // would collapse these five payloads and trip at the default threshold.
1588+ const { reactor, events, waitFor } = createTestReactor ( {
1589+ director : createBatchLoopDirector ( ( turn ) =>
1590+ turn < 5
1591+ ? [
1592+ {
1593+ id : `c${ turn } ` ,
1594+ name : "run_shell" ,
1595+ arguments : { command : `./true --fix-${ turn } ` } ,
1596+ } ,
1597+ ]
1598+ : null ,
1599+ ) ,
1600+ } ) ;
1601+
1602+ reactor . start ( ) ;
1603+ reactor . deliver ( makeInboundMessage ( ) ) ;
1604+ await waitFor ( "reactor.done" ) ;
1605+
1606+ expect ( events . some ( ( e ) => e . type === "reactor.error" ) ) . toBe ( false ) ;
1607+ expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
1608+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 5 ) ;
1609+ } ) ;
1610+
1611+ test ( "does not collapse relative-path echo into trivial churn" , async ( ) => {
1612+ const { reactor, events, waitFor } = createTestReactor ( {
1613+ director : createBatchLoopDirector ( ( turn ) =>
1614+ turn < 5
1615+ ? [
1616+ {
1617+ id : `c${ turn } ` ,
1618+ name : "run_shell" ,
1619+ arguments : { command : `scripts/echo payload-${ turn } ` } ,
1620+ } ,
1621+ ]
1622+ : null ,
1623+ ) ,
1624+ } ) ;
1625+
1626+ reactor . start ( ) ;
1627+ reactor . deliver ( makeInboundMessage ( ) ) ;
1628+ await waitFor ( "reactor.done" ) ;
1629+
1630+ expect ( events . some ( ( e ) => e . type === "reactor.error" ) ) . toBe ( false ) ;
1631+ expect ( getEvent ( events , "message.run.ended" ) . data . status ) . toBe ( "completed" ) ;
1632+ expect ( events . filter ( ( e ) => e . type === "tool.start" ) . length ) . toBe ( 5 ) ;
1633+ } ) ;
1634+
14021635 test ( "treats key-reordered arguments as identical" , async ( ) => {
14031636 // Same logical arguments, keys emitted in different orders. Canonical
14041637 // serialization must collapse them to one signature, so three such turns
0 commit comments