Skip to content

Commit 6476dae

Browse files
AlfredAlfred
authored andcommitted
fix(subagents): rebase onto main — keep no-deliverable-payload notice slice
RC1 (wait-timer pending-timeout forwarding) is superseded on main: shared terminal-outcome classifier with fresh-wait isolation. This branch now carries only the delivery-layer slice: - Synthesize a bounded, closed-vocabulary terminal notice for no-deliverable-payload subagent completions (statusLabel is never echoed; reasons are limited to no visible reply / timed out / failed (details withheld) / incomplete). - Reorder the message-tool-only delivery branch so the text-direct fallback runs before the failed-no-output early-out. - Regression tests for the notice vocabulary (incl. sensitive-label cases) plus wait-timer hard/soft coverage locking main's #89367 semantics. Restart-cancelled completions resolve through main's direct terminal path by design and are not re-tested here.
1 parent 7c72fef commit 6476dae

3 files changed

Lines changed: 362 additions & 13 deletions

File tree

src/agents/subagent-announce-delivery.test.ts

Lines changed: 232 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
/^Background task finished without producing a visible reply \(status: error, reason: no visible reply\)\.$/,
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 () => {

src/agents/subagent-announce-delivery.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,36 @@ function resolveTextCompletionDirectFallback(events: readonly AgentInternalEvent
10991099
return result;
11001100
}
11011101
}
1102+
// Second pass: synthesize a terminal notice when no ok-with-content completion
1103+
// exists but at least one task_completion is present. Without this, a direct-
1104+
// channel parent waiting via sessions_yield never sees a terminal message when
1105+
// the child stops with no deliverable payload — common when context-overflow
1106+
// compaction trims the wrap-up turn, when the child is force-killed by run
1107+
// timeout, or any clean stopReason=stop completion that emits no payload.
1108+
// The notice is built from a closed vocabulary only — event.statusLabel can
1109+
// carry raw outcome.error text (provider errors, paths), which must never be
1110+
// echoed to an external channel. See
1111+
// https://github.com/openclaw/openclaw/issues/89095.
1112+
for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) {
1113+
const event = events?.[index];
1114+
if (event?.type !== "task_completion" || event.source !== "subagent") {
1115+
continue;
1116+
}
1117+
const status =
1118+
event.status === "ok" || event.status === "timeout" || event.status === "error"
1119+
? event.status
1120+
: "unknown";
1121+
const rawResult = typeof event.result === "string" ? event.result.trim() : "";
1122+
const reasonHint =
1123+
rawResult === "(no output)" || rawResult === ""
1124+
? "no visible reply"
1125+
: status === "timeout"
1126+
? "timed out"
1127+
: status === "error"
1128+
? "failed (details withheld)"
1129+
: "incomplete";
1130+
return `Background task finished without producing a visible reply (status: ${status}, reason: ${reasonHint}).`;
1131+
}
11021132
return undefined;
11031133
}
11041134

@@ -1806,14 +1836,14 @@ async function sendSubagentAnnounceDirectly(params: {
18061836
!hasGatewayAgentMessagingToolDeliveryEvidence(directAnnounceResponse) &&
18071837
!hasIntentionalSilentGatewayAgentPayload(directAnnounceResponse)
18081838
) {
1809-
if (hasFailedSubagentNoOutputCompletion(params.internalEvents)) {
1810-
return {
1811-
delivered: false,
1812-
path: "direct",
1813-
reason: "visible_reply_missing",
1814-
error: "completion agent did not produce a visible reply",
1815-
};
1816-
}
1839+
// Try the direct text-completion fallback FIRST. The synth notice in
1840+
// resolveTextCompletionDirectFallback now always returns content when any
1841+
// task_completion event exists (including failed/timeout/empty payloads),
1842+
// so a parent yielded via sessions_yield receives a bounded terminal
1843+
// notice rather than silently retry-abandoning. Without reordering, the
1844+
// hasFailedSubagentNoOutputCompletion gate below short-circuits to
1845+
// visible_reply_missing for the exact "(no output)" case the synth path
1846+
// is meant to cover. See https://github.com/openclaw/openclaw/issues/89095.
18171847
if (subagentDirectMessageCompletionRequiresMessageTool) {
18181848
const textDelivery = await deliverTextCompletionDirect({
18191849
cfg,
@@ -1826,6 +1856,14 @@ async function sendSubagentAnnounceDirectly(params: {
18261856
return textDelivery;
18271857
}
18281858
}
1859+
if (hasFailedSubagentNoOutputCompletion(params.internalEvents)) {
1860+
return {
1861+
delivered: false,
1862+
path: "direct",
1863+
reason: "visible_reply_missing",
1864+
error: "completion agent did not produce a visible reply",
1865+
};
1866+
}
18291867
return {
18301868
delivered: false,
18311869
path: "direct",

0 commit comments

Comments
 (0)