fix(telegram): suppress 'no extra answer' placeholder when reply is in flight (#78929)

This commit is contained in:
Andrew Cunliffe
2026-05-07 17:01:28 -07:00
committed by Ayaan Zaidi
parent fa2ffa6fbe
commit 048ca8c765
3 changed files with 49 additions and 2 deletions

View File

@@ -286,6 +286,7 @@ Docs: https://docs.openclaw.ai
- Gateway/pairing: preserve deliberately narrowed role-token scopes when approving device scope upgrades instead of regranting the whole approved baseline.
- Telegram/ACP: keep chat-bound ACP replies durable by delivering final-only ACP output as final text instead of transient Telegram preview blocks. Thanks @shakkernerd.
- Telegram: hydrate replied-to messages as a persisted nearest-first reply chain so agents can see observed parent text, media refs, captions, senders, timestamps, and nested replies instead of guessing from a shallow reply id.
- Telegram: skip the rewritten silent-reply fallback when the dispatcher reports a final reply was queued in the same turn so a "No extra answer from me." filler cannot race ahead of the actual reply when lane delivery state never observes the send. Fixes #78929.
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
- Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs.
- CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive.

View File

@@ -365,6 +365,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
};
}
function createDirectSessionPayload(): TelegramMessageContext["ctxPayload"] {
return {
SessionKey: "agent:test:telegram:direct:123",
ChatType: "direct",
} as TelegramMessageContext["ctxPayload"];
}
function observeDeliveredReply(text: string): Promise<void> {
return new Promise((resolve) => {
deliverReplies.mockImplementation(async (params: { replies?: Array<{ text?: string }> }) => {
@@ -1644,4 +1651,41 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(generateTopicLabel).not.toHaveBeenCalled();
expect(bot.api.editForumTopic).not.toHaveBeenCalled();
});
it("does not emit a silent-reply fallback when the dispatcher reports a queued final reply", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: true,
counts: { block: 0, final: 1, tool: 0 },
});
await dispatchWithContext({
context: createContext({
ctxPayload: createDirectSessionPayload(),
}),
streamMode: "off",
});
expect(deliverReplies).not.toHaveBeenCalled();
});
it("emits a silent-reply fallback when no final reply was queued and nothing was delivered", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
});
await dispatchWithContext({
context: createContext({
ctxPayload: createDirectSessionPayload(),
}),
streamMode: "off",
});
expect(deliverReplies).toHaveBeenCalledTimes(1);
const replies = deliverReplies.mock.calls[0]?.[0]?.replies as
| Array<{ text?: string }>
| undefined;
expect(replies?.[0]?.text?.trim()).toBeTruthy();
expect(replies?.[0]?.text).not.toBe("NO_REPLY");
});
});

View File

@@ -1543,7 +1543,8 @@ export const dispatchTelegramMessage = async ({
!sentFallback &&
!dispatchError &&
!deliverySummary.delivered &&
!suppressSilentReplyFallback
!suppressSilentReplyFallback &&
!queuedFinal
) {
const policySessionKey =
ctxPayload.CommandSource === "native"
@@ -1573,7 +1574,8 @@ export const dispatchTelegramMessage = async ({
});
}
const hasFinalResponse = deliverySummary.delivered || sentFallback || suppressSilentReplyFallback;
const hasFinalResponse =
deliverySummary.delivered || sentFallback || suppressSilentReplyFallback || queuedFinal;
if (statusReactionController && !hasFinalResponse) {
void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch(