mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 04:23:32 +00:00
fix(telegram): make progress-window collapse consistent across all modes
Live on-off finding: the collapse path was inconsistent. Edit-in-place worked in off-off/stream-off, but in on-off (tool-progress-only window, reasoning durable) finalizeToPreview returned undefined and the caller fell into a bare clear() -> deleteMessage -> Telegram focus-jump; a sibling sub-branch dropped the bar entirely. Two divergent outcomes from one fallback. Root cause: a throttled tool-progress preview could still be pending (coalesced, never sent) when the turn ended, so the window had no message id even though it had "rendered". finalizeToPreview gave up and the dispatch fallback deleted the (late-landing) window. Fixes: - draft-stream finalizeToPreview: settle the stream, then MATERIALIZE a still- pending preview (send it, as a final flush would) so the window message exists and can be edited in place. Only when no message could be established does it return undefined. (draft-stream.ts:597) - dispatch collapse: one deterministic path returning "edited" | "posted" | "none". A bar is ALWAYS surfaced when one exists — edited in place, or posted durably with ZERO deleteMessage. clear()/delete now runs ONLY for the "none" case (error final or nothing to summarize), never when a bar exists, so no collapse path can focus-jump. Split into resolveProgressCollapseSummaryLine / collapseProgressWindowIntoSummary / resetAnswerLaneAfterCollapse / teardownProgressWindow for a readable branch. (bot-message-dispatch.ts:1949) Tests: added on-off dispatch repro (tool-progress-only + durable reasoning + mid-turn rotation + final -> edits into bar, no clear, exactly one bar), the no-live-message durable-bar-without-delete case, and three draft-stream finalizeToPreview cases (edit-in-place, pending-materialize, no-window). 225 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2822,6 +2822,76 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expectDeliveredReply(0, { text: "Done" });
|
||||
});
|
||||
|
||||
it("collapses a tool-progress-only window without deleting when reasoning is durable and the lane rotated mid-turn (on-off)", async () => {
|
||||
// on-off cell: /reasoning on (durable), /verbose off. The window streams
|
||||
// tool progress only; a mid-turn assistant boundary/rotation must not leave
|
||||
// the collapse to a delete + repost. Every non-error collapse edits in place
|
||||
// (or posts the bar durably) — NEVER a bare clear()/deleteMessage — so there
|
||||
// is exactly one bar and no Telegram focus-jump.
|
||||
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
// Durable reasoning + an assistant boundary land between tool progress
|
||||
// and the final — the mid-turn churn that dropped the live window id.
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "<think>hidden</think>", isReasoning: true },
|
||||
{ kind: "block" },
|
||||
);
|
||||
await replyOptions?.onAssistantMessageStart?.();
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
// Collapse edited the window in place into the bar; the window was NOT
|
||||
// deleted (no focus-jump), and exactly one bar exists.
|
||||
expectWindowCollapsedTo(answerDraftStream, "🛠️ 2 tool calls · ⏱️ 1s");
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
const texts = allDeliveredReplyTexts();
|
||||
expect(texts.filter((text) => text.includes("⏱️"))).toHaveLength(0); // bar is the in-place edit
|
||||
expect(texts).toContain("Done");
|
||||
});
|
||||
|
||||
it("posts the collapse bar durably with no delete when the window has no live message", async () => {
|
||||
// When finalizeToPreview cannot edit in place (no live window message id),
|
||||
// the bar is still surfaced — as a durable post — and the window is NOT
|
||||
// cleared/deleted (nothing to delete; never a bare clear when a bar exists).
|
||||
const answerDraftStream = createTestDraftStream({}); // no messageId -> edit fails
|
||||
const reasoningDraftStream = createTestDraftStream({});
|
||||
createTelegramDraftStream
|
||||
.mockImplementationOnce(() => answerDraftStream)
|
||||
.mockImplementationOnce(() => reasoningDraftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
const texts = allDeliveredReplyTexts();
|
||||
expect(texts.filter((text) => text.includes("⏱️"))).toEqual(["🛠️ 1 tool call · ⏱️ 1s"]);
|
||||
expect(texts).toContain("Done");
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not duplicate tool lines into the window under verbose", async () => {
|
||||
// Invariant D2 (persistent XOR window): when the durable verbose lane owns
|
||||
// tool messages, the window must render no tool line and must not count it.
|
||||
|
||||
@@ -1946,54 +1946,73 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
await sendPayload({ text: line }, { durable: true });
|
||||
};
|
||||
// Collapse the live window IN PLACE into the summary bar: edit the existing
|
||||
// window message so its content becomes the bar line, keeping it on screen.
|
||||
// Mirrors Discord — deleting the window and reposting the bar scroll-jumps
|
||||
// the Telegram client and flashes the window away. Returns true when the
|
||||
// window was collapsed in place; false when there is no bar (nothing
|
||||
// streamed) or no live window message, so the caller tears the window down.
|
||||
const collapseProgressWindowIntoSummary = async (): Promise<boolean> => {
|
||||
// Collapse the progress window into the summary bar. ONE deterministic path
|
||||
// for every mode (off-off, stream-off, on-off tool-progress-only): edit the
|
||||
// live window message IN PLACE into the bar (no delete — deleting scroll-
|
||||
// jumps the Telegram client). Only when there is genuinely no live window
|
||||
// message (rv mode never rendered a message) is the bar posted durably, and
|
||||
// even then NOTHING is deleted. Returns "edited" | "posted" | "none" so the
|
||||
// caller resets lane state without ever falling back to a bare clear() when
|
||||
// a bar exists. finalizeToPreview settles pending previews first, so a
|
||||
// still-pending tool-progress window is materialized and edited rather than
|
||||
// missed (the on-off inconsistency).
|
||||
const collapseProgressWindowIntoSummary = async (): Promise<"edited" | "posted" | "none"> => {
|
||||
const line = resolveProgressCollapseSummaryLine();
|
||||
if (!line) {
|
||||
return false;
|
||||
return "none";
|
||||
}
|
||||
const messageId = await answerLane.stream?.finalizeToPreview(renderStreamText(line));
|
||||
if (typeof messageId === "number") {
|
||||
return true;
|
||||
return "edited";
|
||||
}
|
||||
// No live window to edit (rv mode, never rendered): keep the bar as a
|
||||
// fresh durable post so the timeline still shows the collapse summary.
|
||||
// No live window message existed to edit; still surface the bar, but never
|
||||
// delete (there is nothing on screen to remove).
|
||||
await sendPayload({ text: line }, { durable: true });
|
||||
return false;
|
||||
return "posted";
|
||||
};
|
||||
// Reset answer-lane bookkeeping after a bar was edited/posted in place,
|
||||
// WITHOUT clear() — the window message stays (as the bar) and must not be
|
||||
// deleted (no focus-jump). forceNewMessage only rewinds the stream so the
|
||||
// next send starts a new message.
|
||||
const resetAnswerLaneAfterCollapse = () => {
|
||||
if (activeAnswerDraftIsToolProgressOnly) {
|
||||
resetAnswerToolProgressDraft();
|
||||
suppressProgressDraftState();
|
||||
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(answerLane);
|
||||
};
|
||||
// Tear the window down (delete) — only when there is NO bar to keep it on
|
||||
// screen for (error final, or a turn with nothing to summarize). A bar
|
||||
// collapse never reaches here, so clear()/delete never runs when a bar
|
||||
// exists (the on-off focus-jump).
|
||||
const teardownProgressWindow = async () => {
|
||||
if (activeAnswerDraftIsToolProgressOnly) {
|
||||
await rotateAnswerLaneAfterToolProgress();
|
||||
} else {
|
||||
await answerLane.stream?.clear();
|
||||
resetDraftLaneState(answerLane);
|
||||
}
|
||||
};
|
||||
const deliverProgressModeFinalAnswer = async (
|
||||
payload: ReplyPayload,
|
||||
text: string,
|
||||
): Promise<LaneDeliveryResult> => {
|
||||
// Collapse the window into the bar in place BEFORE resetting lane state
|
||||
// (which drops the stream's message id). Error finals get no summary
|
||||
// (Discord parity). When nothing collapsed in place, tear the window down
|
||||
// so a stale progress box does not linger above the final answer.
|
||||
const collapsedInPlace =
|
||||
payload.isError === true ? false : await collapseProgressWindowIntoSummary();
|
||||
if (payload.isError === true) {
|
||||
// Error finals get no collapse summary (Discord parity); tear down.
|
||||
progressSummaryDelivered = true;
|
||||
}
|
||||
if (!collapsedInPlace) {
|
||||
if (activeAnswerDraftIsToolProgressOnly) {
|
||||
await rotateAnswerLaneAfterToolProgress();
|
||||
} else {
|
||||
await answerLane.stream?.clear();
|
||||
resetDraftLaneState(answerLane);
|
||||
}
|
||||
await teardownProgressWindow();
|
||||
} else {
|
||||
if (activeAnswerDraftIsToolProgressOnly) {
|
||||
resetAnswerToolProgressDraft();
|
||||
suppressProgressDraftState();
|
||||
rotateAnswerLaneWhenQueuedBlocksSettle = false;
|
||||
// Collapse BEFORE resetting lane state (which drops the stream's message
|
||||
// id). "edited"/"posted" keep a bar on screen — reset without delete;
|
||||
// "none" (nothing to summarize) tears the stale window down.
|
||||
const outcome = await collapseProgressWindowIntoSummary();
|
||||
if (outcome === "none") {
|
||||
await teardownProgressWindow();
|
||||
} else {
|
||||
resetAnswerLaneAfterCollapse();
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(answerLane);
|
||||
}
|
||||
const delivered = await sendPayload(applyTextToPayload(payload, text), { durable: true });
|
||||
if (!delivered) {
|
||||
|
||||
@@ -312,6 +312,50 @@ describe("createTelegramDraftStream", () => {
|
||||
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizeToPreview edits the live window message in place without deleting", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } });
|
||||
|
||||
stream.update("🛠️ Exec: pnpm test");
|
||||
await stream.flush();
|
||||
const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" });
|
||||
|
||||
expect(messageId).toBe(17);
|
||||
// The window message is EDITED into the bar, never deleted (no focus-jump).
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "🛠️ 1 tool call · ⏱️ 1s");
|
||||
expect(api.deleteMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizeToPreview materializes a still-pending window before editing", async () => {
|
||||
// A throttled preview may not have been sent yet when the collapse runs;
|
||||
// finalizeToPreview must send it first so there is a message to edit into
|
||||
// the bar, rather than returning undefined and forcing a delete + repost.
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, {
|
||||
thread: { id: 42, scope: "dm" },
|
||||
throttleMs: 10_000,
|
||||
});
|
||||
|
||||
stream.update("🛠️ Exec: pnpm test");
|
||||
const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" });
|
||||
|
||||
expect(messageId).toBe(17);
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(api.deleteMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finalizeToPreview returns undefined when no window ever rendered", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } });
|
||||
|
||||
const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" });
|
||||
|
||||
expect(messageId).toBeUndefined();
|
||||
expect(api.sendMessage).not.toHaveBeenCalled();
|
||||
expect(api.editMessageText).not.toHaveBeenCalled();
|
||||
expect(api.deleteMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes message preview on clear after finalization", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -597,13 +597,27 @@ export function createTelegramDraftStream(params: {
|
||||
const finalizeToPreview = async (
|
||||
preview: TelegramDraftPreview,
|
||||
): Promise<number | undefined> => {
|
||||
const text = preview.text.trimEnd();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
// Settle pending updates so we edit the real, current window message.
|
||||
streamState.final = true;
|
||||
await loop.flush();
|
||||
const text = preview.text.trimEnd();
|
||||
// No live window message to edit (never rendered, or already torn down):
|
||||
// nothing to collapse in place — caller falls back to a fresh bar post.
|
||||
if (typeof streamMessageId !== "number" || !text) {
|
||||
// A throttled preview can still be pending (the last tool-progress line was
|
||||
// coalesced and never sent), leaving no message id even though the window
|
||||
// "rendered". Materialize it as a final flush would, so the window message
|
||||
// exists and can be edited in place — otherwise on-off collapses missed it
|
||||
// and fell back to a delete + repost.
|
||||
if (typeof streamMessageId !== "number" && !streamState.stopped) {
|
||||
const pending = lastRequestedText.trimEnd();
|
||||
if (pending && pending !== lastDeliveredText.trimEnd()) {
|
||||
await sendOrEditStreamMessage(pending);
|
||||
}
|
||||
}
|
||||
// Genuinely no live window message (rv mode never rendered): caller posts a
|
||||
// fresh durable bar instead — but it must NOT delete anything.
|
||||
if (typeof streamMessageId !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
// Replace the whole message with the bar line: edits diff from a zero
|
||||
|
||||
Reference in New Issue
Block a user