@@ -1385,7 +1385,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
13851385 } ) ;
13861386 } ) ;
13871387
1388- it ( "does not directly deliver failed subagent placeholder output" , async ( ) => {
1388+ it ( "delivers a synthesized terminal notice when a direct-message subagent fails with no output (#89095) " , async ( ) => {
13891389 const callGateway = createGatewayMock ( {
13901390 result : {
13911391 payloads : [ ] ,
@@ -1413,12 +1413,239 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
14131413 } ) ;
14141414
14151415 expectRecordFields ( result , {
1416- delivered : false ,
1416+ delivered : true ,
14171417 path : "direct" ,
1418- reason : "visible_reply_missing" ,
1419- error : "completion agent did not produce a visible reply" ,
14201418 } ) ;
1421- expect ( sendMessage ) . not . toHaveBeenCalled ( ) ;
1419+ expect ( sendMessage ) . toHaveBeenCalledWith (
1420+ expect . objectContaining ( {
1421+ content : expect . stringMatching (
1422+ / ^ B a c k g r o u n d t a s k f i n i s h e d w i t h o u t p r o d u c i n g a v i s i b l e r e p l y \( s t a t u s : e r r o r , r e a s o n : n o v i s i b l e r e p l y \) \. $ / ,
1423+ ) ,
1424+ idempotencyKey : "announce-dm-fallback-empty:text-direct" ,
1425+ } ) ,
1426+ ) ;
1427+ } ) ;
1428+
1429+ it ( "delivers a synthesized terminal notice for a clean completion with empty payload (#89095)" , async ( ) => {
1430+ // jrex-jooni's 2026-06-22 verified data point: context-overflow-driven
1431+ // mid-turn compaction reliably produces a cleanly-stopped child (no error,
1432+ // stopReason=stop) whose wrap-up completion turn emits no visible payload.
1433+ // Internally this surfaces as a task_completion with status:"ok" and an
1434+ // empty result. The old fallback only returned ok-with-content, so this
1435+ // case dropped to visible_reply_missing and the parent yielded via
1436+ // sessions_yield never woke.
1437+ const callGateway = createGatewayMock ( {
1438+ result : {
1439+ payloads : [ ] ,
1440+ } ,
1441+ } ) ;
1442+ const sendMessage = createSendMessageMock ( ) ;
1443+
1444+ const result = await deliverDiscordDirectMessageCompletion ( {
1445+ callGateway,
1446+ sendMessage,
1447+ internalEvents : [
1448+ {
1449+ type : "task_completion" ,
1450+ source : "subagent" ,
1451+ childSessionKey : "agent:worker:subagent:child" ,
1452+ childSessionId : "child-session-id" ,
1453+ announceType : "subagent task" ,
1454+ taskLabel : "clean stop empty payload" ,
1455+ status : "ok" ,
1456+ statusLabel : "completed" ,
1457+ result : "" ,
1458+ replyInstruction : "Summarize the result." ,
1459+ } ,
1460+ ] ,
1461+ } ) ;
1462+
1463+ expectRecordFields ( result , {
1464+ delivered : true ,
1465+ path : "direct" ,
1466+ } ) ;
1467+ expect ( sendMessage ) . toHaveBeenCalledWith (
1468+ expect . objectContaining ( {
1469+ content :
1470+ "Background task finished without producing a visible reply (status: ok, reason: no visible reply)." ,
1471+ } ) ,
1472+ ) ;
1473+ } ) ;
1474+
1475+ it ( "delivers a synthesized terminal notice for a timeout-killed subagent with no output (#89095)" , async ( ) => {
1476+ // sunnydongbo's verified case: runTimeoutSeconds force-kill of a blocking
1477+ // child (sleep 600) produced status: "timeout" + result: "(no output)";
1478+ // wait-timer fix alone is necessary but not sufficient — without the synth
1479+ // path, delivery still abandons after 3 retries with
1480+ // "completion agent did not produce a visible reply".
1481+ const callGateway = createGatewayMock ( {
1482+ result : {
1483+ payloads : [ ] ,
1484+ } ,
1485+ } ) ;
1486+ const sendMessage = createSendMessageMock ( ) ;
1487+
1488+ const result = await deliverDiscordDirectMessageCompletion ( {
1489+ callGateway,
1490+ sendMessage,
1491+ internalEvents : [
1492+ {
1493+ type : "task_completion" ,
1494+ source : "subagent" ,
1495+ childSessionKey : "agent:worker:subagent:child" ,
1496+ childSessionId : "child-session-id" ,
1497+ announceType : "subagent task" ,
1498+ taskLabel : "timeout-killed child" ,
1499+ status : "timeout" ,
1500+ statusLabel : "timed out" ,
1501+ result : "(no output)" ,
1502+ replyInstruction : "Summarize the result." ,
1503+ } ,
1504+ ] ,
1505+ } ) ;
1506+
1507+ expectRecordFields ( result , {
1508+ delivered : true ,
1509+ path : "direct" ,
1510+ } ) ;
1511+ expect ( sendMessage ) . toHaveBeenCalledWith (
1512+ expect . objectContaining ( {
1513+ content :
1514+ "Background task finished without producing a visible reply (status: timeout, reason: no visible reply)." ,
1515+ } ) ,
1516+ ) ;
1517+ } ) ;
1518+
1519+ it ( "synthesized terminal notice does not leak raw child output (#89095)" , async ( ) => {
1520+ // Maintainer-stated design constraint: the synth notice must be bounded
1521+ // and must not echo raw child output. Verify both for an error-status
1522+ // completion that carries a long, sensitive-looking error string.
1523+ const callGateway = createGatewayMock ( {
1524+ result : {
1525+ payloads : [ ] ,
1526+ } ,
1527+ } ) ;
1528+ const sendMessage = createSendMessageMock ( ) ;
1529+
1530+ const sensitiveStatusLabel =
1531+ "failed: API key sk-redacted-1234567890abcdef expired; rotate via https://internal/keys/123" ;
1532+ const result = await deliverDiscordDirectMessageCompletion ( {
1533+ callGateway,
1534+ sendMessage,
1535+ internalEvents : [
1536+ {
1537+ type : "task_completion" ,
1538+ source : "subagent" ,
1539+ childSessionKey : "agent:worker:subagent:child" ,
1540+ childSessionId : "child-session-id" ,
1541+ announceType : "subagent task" ,
1542+ taskLabel : "long error completion" ,
1543+ status : "error" ,
1544+ statusLabel : sensitiveStatusLabel ,
1545+ result : "(no output)" ,
1546+ replyInstruction : "Summarize the result." ,
1547+ } ,
1548+ ] ,
1549+ } ) ;
1550+
1551+ expectRecordFields ( result , {
1552+ delivered : true ,
1553+ path : "direct" ,
1554+ } ) ;
1555+ const deliveredContent = ( sendMessage as ReturnType < typeof vi . fn > ) . mock . calls [ 0 ] ?. [ 0 ]
1556+ ?. content as string ;
1557+ expect ( deliveredContent ) . toBeTruthy ( ) ;
1558+ // Notice is bounded: well under any channel's per-message budget.
1559+ expect ( deliveredContent . length ) . toBeLessThan ( 200 ) ;
1560+ // Notice includes the error reason but not the raw secret-bearing fragment.
1561+ expect ( deliveredContent ) . toContain (
1562+ "Background task finished without producing a visible reply" ,
1563+ ) ;
1564+ expect ( deliveredContent ) . not . toContain ( "sk-redacted-1234567890abcdef" ) ;
1565+ expect ( deliveredContent ) . not . toContain ( "https://internal/keys/123" ) ;
1566+ } ) ;
1567+
1568+ it ( "never echoes statusLabel in the synth notice, even for non-empty results (#89095)" , async ( ) => {
1569+ // Review-hardened path: when the completion carries content the first pass
1570+ // rejects (non-ok status), the synth reason previously fell through to the
1571+ // raw statusLabel — which is outcome.error-derived and can carry provider
1572+ // error text, paths, or secrets. The reason must come from the closed
1573+ // vocabulary only.
1574+ const callGateway = createGatewayMock ( {
1575+ result : {
1576+ payloads : [ ] ,
1577+ } ,
1578+ } ) ;
1579+ const sendMessage = createSendMessageMock ( ) ;
1580+
1581+ const result = await deliverDiscordDirectMessageCompletion ( {
1582+ callGateway,
1583+ sendMessage,
1584+ internalEvents : [
1585+ {
1586+ type : "task_completion" ,
1587+ source : "subagent" ,
1588+ childSessionKey : "agent:worker:subagent:child" ,
1589+ childSessionId : "child-session-id" ,
1590+ announceType : "subagent task" ,
1591+ taskLabel : "error completion with partial output" ,
1592+ status : "error" ,
1593+ statusLabel : "failed: ENOENT reading /Users/someone/.ssh/id_rsa during provider call" ,
1594+ result : "partial output the child produced before dying" ,
1595+ replyInstruction : "Summarize the result." ,
1596+ } ,
1597+ ] ,
1598+ } ) ;
1599+
1600+ expectRecordFields ( result , {
1601+ delivered : true ,
1602+ path : "direct" ,
1603+ } ) ;
1604+ expect ( sendMessage ) . toHaveBeenCalledWith (
1605+ expect . objectContaining ( {
1606+ content :
1607+ "Background task finished without producing a visible reply (status: error, reason: failed (details withheld))." ,
1608+ } ) ,
1609+ ) ;
1610+ } ) ;
1611+
1612+ it ( "maps a timeout completion with partial output to the closed timed-out reason (#89095)" , async ( ) => {
1613+ const callGateway = createGatewayMock ( {
1614+ result : {
1615+ payloads : [ ] ,
1616+ } ,
1617+ } ) ;
1618+ const sendMessage = createSendMessageMock ( ) ;
1619+
1620+ const result = await deliverDiscordDirectMessageCompletion ( {
1621+ callGateway,
1622+ sendMessage,
1623+ internalEvents : [
1624+ {
1625+ type : "task_completion" ,
1626+ source : "subagent" ,
1627+ childSessionKey : "agent:worker:subagent:child" ,
1628+ childSessionId : "child-session-id" ,
1629+ announceType : "subagent task" ,
1630+ taskLabel : "timeout completion with partial output" ,
1631+ status : "timeout" ,
1632+ statusLabel : "timed out after runTimeoutSeconds=600 killed pid 12345" ,
1633+ result : "partial output before the force-kill" ,
1634+ replyInstruction : "Summarize the result." ,
1635+ } ,
1636+ ] ,
1637+ } ) ;
1638+
1639+ expectRecordFields ( result , {
1640+ delivered : true ,
1641+ path : "direct" ,
1642+ } ) ;
1643+ expect ( sendMessage ) . toHaveBeenCalledWith (
1644+ expect . objectContaining ( {
1645+ content :
1646+ "Background task finished without producing a visible reply (status: timeout, reason: timed out)." ,
1647+ } ) ,
1648+ ) ;
14221649 } ) ;
14231650
14241651 it ( "directly delivers unprefixed direct targets recognized by the channel grammar" , async ( ) => {
0 commit comments