mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 01:27:14 +00:00
fix: preserve telegram recovered reply and one-time finalization
This commit is contained in:
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
|
||||
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
|
||||
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
|
||||
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
|
||||
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
|
||||
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
|
||||
@@ -184,6 +184,29 @@ describe("buildEmbeddedRunPayloads", () => {
|
||||
expect(payloads[0]?.text).toContain("code 1");
|
||||
});
|
||||
|
||||
it("does not add tool error fallback when assistant text exists after tool calls", () => {
|
||||
const payloads = buildPayloads({
|
||||
assistantTexts: ["Checked the page and recovered with final answer."],
|
||||
lastAssistant: makeAssistant({
|
||||
stopReason: "toolUse",
|
||||
errorMessage: undefined,
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_01",
|
||||
name: "browser",
|
||||
arguments: { action: "search", query: "openclaw docs" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
lastToolError: { toolName: "browser", error: "connection timeout" },
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.isError).toBeUndefined();
|
||||
expect(payloads[0]?.text).toContain("recovered");
|
||||
});
|
||||
|
||||
it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => {
|
||||
const payloads = buildPayloads({
|
||||
lastToolError: { toolName: "browser", error: "url required" },
|
||||
|
||||
@@ -218,6 +218,7 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
: []
|
||||
).filter((text) => !shouldSuppressRawErrorText(text));
|
||||
|
||||
let hasUserFacingAssistantReply = false;
|
||||
for (const text of answerTexts) {
|
||||
const {
|
||||
text: cleanedText,
|
||||
@@ -238,22 +239,13 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
replyToTag,
|
||||
replyToCurrent,
|
||||
});
|
||||
hasUserFacingAssistantReply = true;
|
||||
}
|
||||
|
||||
if (params.lastToolError) {
|
||||
const lastAssistantHasToolCalls =
|
||||
Array.isArray(params.lastAssistant?.content) &&
|
||||
params.lastAssistant?.content.some((block) =>
|
||||
block && typeof block === "object"
|
||||
? (block as { type?: unknown }).type === "toolCall"
|
||||
: false,
|
||||
);
|
||||
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
|
||||
const hasUserFacingReply =
|
||||
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
|
||||
const shouldShowToolError = shouldShowToolErrorWarning({
|
||||
lastToolError: params.lastToolError,
|
||||
hasUserFacingReply,
|
||||
hasUserFacingReply: hasUserFacingAssistantReply,
|
||||
suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors),
|
||||
});
|
||||
|
||||
|
||||
@@ -216,6 +216,38 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not overwrite finalized preview when additional final payloads are sent", async () => {
|
||||
const draftStream = createDraftStream(999);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" });
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "⚠️ Recovered tool error details" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
deliverReplies.mockResolvedValue({ delivered: true });
|
||||
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
expect(editMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(editMessageTelegram).toHaveBeenCalledWith(
|
||||
123,
|
||||
999,
|
||||
"Primary result",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(deliverReplies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replies: [expect.objectContaining({ text: "⚠️ Recovered tool error details" })],
|
||||
}),
|
||||
);
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
expect(draftStream.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when preview final is too long to edit", async () => {
|
||||
const draftStream = createDraftStream(999);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
|
||||
@@ -315,33 +315,33 @@ export const dispatchTelegramMessage = async ({
|
||||
finalText.length <= draftMaxChars &&
|
||||
!payload.isError;
|
||||
if (canFinalizeViaPreviewEdit) {
|
||||
draftStream?.stop();
|
||||
draftStoppedForPreviewEdit = true;
|
||||
if (
|
||||
currentPreviewText &&
|
||||
currentPreviewText.startsWith(finalText) &&
|
||||
finalText.length < currentPreviewText.length
|
||||
) {
|
||||
// Ignore regressive final edits (e.g., "Okay." -> "Ok"), which
|
||||
// can appear transiently in some provider streams.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await editMessageTelegram(chatId, previewMessageId, finalText, {
|
||||
api: bot.api,
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
buttons: previewButtons,
|
||||
});
|
||||
finalizedViaPreviewMessage = true;
|
||||
deliveryState.delivered = true;
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`telegram: preview final edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
}
|
||||
draftStream?.stop();
|
||||
draftStoppedForPreviewEdit = true;
|
||||
if (
|
||||
currentPreviewText &&
|
||||
currentPreviewText.startsWith(finalText) &&
|
||||
finalText.length < currentPreviewText.length
|
||||
) {
|
||||
// Ignore regressive final edits (e.g., "Okay." -> "Ok"), which
|
||||
// can appear transiently in some provider streams.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await editMessageTelegram(chatId, previewMessageId, finalText, {
|
||||
api: bot.api,
|
||||
cfg,
|
||||
accountId: route.accountId,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
buttons: previewButtons,
|
||||
});
|
||||
finalizedViaPreviewMessage = true;
|
||||
deliveryState.delivered = true;
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`telegram: preview final edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!hasMedia &&
|
||||
|
||||
Reference in New Issue
Block a user