Skip to content

Commit 894d4c1

Browse files
committed
fix(heartbeat): suppress duplicate post-send narration
1 parent 16f658d commit 894d4c1

11 files changed

Lines changed: 652 additions & 6 deletions

src/agents/embedded-agent-messaging.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type MessagingToolSend = {
1616
hasRichContent?: true;
1717
/** Present only when Codex classified this current-source delivery intent. */
1818
sourceReplyFinal?: boolean;
19+
/** Terminal/progress intent for a successful message_tool_only send. */
20+
messageToolOnlyFinal?: boolean;
1921
};
2022

2123
export type MessagingToolSourceReplyPayload = Pick<

src/agents/embedded-agent-subscribe.handlers.tools.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,6 +1613,46 @@ describe("handleToolExecutionEnd mutating failure recovery", () => {
16131613
]);
16141614
});
16151615

1616+
it("records message-tool-only terminal and progress intent on successful sends", async () => {
1617+
const { ctx } = createTestContext();
1618+
ctx.params.sourceReplyDeliveryMode = "message_tool_only";
1619+
1620+
for (const [toolCallId, final] of [
1621+
["tool-message-terminal", undefined],
1622+
["tool-message-progress", false],
1623+
] as const) {
1624+
await handleToolExecutionStart(
1625+
ctx as never,
1626+
{
1627+
type: "tool_execution_start",
1628+
toolName: "message",
1629+
toolCallId,
1630+
args: {
1631+
action: "send",
1632+
provider: "telegram",
1633+
to: "chat-current",
1634+
text: toolCallId,
1635+
...(final !== undefined ? { final } : {}),
1636+
},
1637+
} as never,
1638+
);
1639+
await handleToolExecutionEnd(
1640+
ctx as never,
1641+
{
1642+
type: "tool_execution_end",
1643+
toolName: "message",
1644+
toolCallId,
1645+
isError: false,
1646+
result: { details: { messageId: `${toolCallId}-result` } },
1647+
} as never,
1648+
);
1649+
}
1650+
1651+
expect(ctx.state.messagingToolSentTargets.map((target) => target.messageToolOnlyFinal)).toEqual(
1652+
[true, false],
1653+
);
1654+
});
1655+
16161656
it("records reply target evidence without treating it as terminal send evidence", async () => {
16171657
const { ctx } = createTestContext();
16181658
const toolCallId = "tool-message-reply-target";

src/agents/embedded-agent-subscribe.handlers.tools.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,12 @@ export async function handleToolExecutionEnd(
14051405
isError: isToolError,
14061406
});
14071407
const messageText = isMessagingSend ? readMessagingText(startArgs) : undefined;
1408+
const messageToolOnlyFinal =
1409+
isMessagingSend && ctx.params.sourceReplyDeliveryMode === "message_tool_only"
1410+
? typeof startArgs.final === "boolean"
1411+
? startArgs.final
1412+
: true
1413+
: undefined;
14081414
const argumentMediaUrls = isMessagingSend ? collectMessagingMediaUrlsFromRecord(startArgs) : [];
14091415
const hasRichContent = isMessagingSend && hasMessagingRichContent(startArgs);
14101416
const messageTarget = hasMessagingTargetEvidence
@@ -1440,6 +1446,7 @@ export async function handleToolExecutionEnd(
14401446
...(messageText ? { text: messageText } : {}),
14411447
...(committedMediaUrls.length > 0 ? { mediaUrls: committedMediaUrls.slice() } : {}),
14421448
...(hasRichContent ? { hasRichContent: true as const } : {}),
1449+
...(messageToolOnlyFinal !== undefined ? { messageToolOnlyFinal } : {}),
14431450
});
14441451
ctx.trimMessagingToolSent();
14451452
}

src/auto-reply/reply-payload.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ export type ReplyPayloadMetadata = {
228228
channel: string;
229229
accountId?: string;
230230
};
231+
/**
232+
* A generic message tool delivery already reached the same provider/target
233+
* route as this reply. Heartbeat uses this route-scoped evidence to avoid
234+
* sending fallback narration after the message tool has delivered the
235+
* visible response.
236+
*/
237+
messageToolDeliveredForReplyRoute?: boolean;
231238
/**
232239
* Internal OpenClaw notices generated after a runtime/provider failure are
233240
* not assistant source replies. Dispatch may deliver them even when normal
@@ -291,6 +298,15 @@ export function markReplyPayloadForSourceSuppressionDelivery<T extends object>(p
291298
});
292299
}
293300

301+
/** Marks a payload whose visible reply was already delivered by a message tool on this route. */
302+
export function markReplyPayloadForMessageToolDeliveryForReplyRoute<T extends object>(
303+
payload: T,
304+
): T {
305+
return setReplyPayloadMetadata(payload, {
306+
messageToolDeliveredForReplyRoute: true,
307+
});
308+
}
309+
294310
export function markCommandReplyForDelivery(
295311
reply: ReplyPayload | ReplyPayload[] | undefined,
296312
): ReplyPayload | ReplyPayload[] | undefined {

0 commit comments

Comments
 (0)