mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
refactor(telegram): remove obsolete progress summary collapse (#127089)
This commit is contained in:
committed by
GitHub
parent
0a0d343cf2
commit
f28bb5e79d
@@ -51,14 +51,11 @@ type TelegramProgressDraftState = {
|
||||
export function createProgressState(
|
||||
config: TurnConfig,
|
||||
draftState: TelegramProgressDraftState,
|
||||
getTurn: () => Turn,
|
||||
prepareAnswerLaneForToolProgress: () => Promise<void>,
|
||||
): TelegramProgressStateSlice {
|
||||
const progressState = {
|
||||
draftEverRendered: false,
|
||||
finalAnswerDeliveryStarted: false,
|
||||
finalAnswerDelivered: false,
|
||||
sawProgressFinal: false,
|
||||
verboseProgressActive: () => false,
|
||||
};
|
||||
const progressCompositor = createChannelProgressDraftCompositor({
|
||||
@@ -80,7 +77,6 @@ export function createProgressState(
|
||||
// headline/checklist mode, so they must not also arrive inside the text.
|
||||
rendersRollingLinesNatively: true,
|
||||
update: async (streamText, options) => {
|
||||
getTurn().draftEverRendered = true;
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
draftState.answerLane.lastPartialText = streamText;
|
||||
draftState.answerLane.hasStreamedMessage = true;
|
||||
@@ -159,7 +155,6 @@ export function markFinalStarted(turn: Turn): void {
|
||||
|
||||
export function markFinalDelivered(turn: Turn): void {
|
||||
turn.finalAnswerDelivered = true;
|
||||
turn.sawProgressFinal = true;
|
||||
turn.progressCompositor.markFinalReplyDelivered();
|
||||
}
|
||||
|
||||
|
||||
@@ -372,8 +372,7 @@ export async function deliverReply(
|
||||
turn.streamMode === "progress" &&
|
||||
info.kind === "block" &&
|
||||
effectivePayload.isCommentary === true;
|
||||
// CLI finals exclude separately classified commentary. Send that block outside
|
||||
// the disposable progress stream or its collapse summary erases the text.
|
||||
// CLI finals exclude separately classified commentary, so it must outlive the progress draft.
|
||||
const suppressProgressAnswerBlock =
|
||||
turn.streamMode === "progress" &&
|
||||
info.kind === "block" &&
|
||||
|
||||
@@ -241,7 +241,6 @@ export async function runTelegramDispatchTurn(turn: Turn) {
|
||||
beginDraftQueuedFollowup(turn);
|
||||
turn.finalAnswerDeliveryStarted = false;
|
||||
turn.finalAnswerDelivered = false;
|
||||
turn.sawProgressFinal = false;
|
||||
turn.progressCompositor.beginNewTurn({ force: true });
|
||||
},
|
||||
onQueuedFollowupSettled: async () => {
|
||||
@@ -314,9 +313,6 @@ export async function runTelegramDispatchTurn(turn: Turn) {
|
||||
turn.agentRunFailed = readAgentRunTerminalOutcome(turnResult.dispatchResult) === "failed";
|
||||
turn.noVisibleReplyFallbackEligible =
|
||||
turnResult.dispatchResult.noVisibleReplyFallbackEligible === true;
|
||||
if (hasFinalInboundReplyDispatch(turnResult.dispatchResult)) {
|
||||
turn.sawProgressFinal = true;
|
||||
}
|
||||
turn.suppressSilentReplyFallback =
|
||||
turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only";
|
||||
return true;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { dispatchReplyWithBufferedBlockDispatcher as dispatchReplyWithBufferedBlockDispatcherRuntime } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { expectWindowRetiredAfterFinal } from "./bot-message-dispatch.progress-window.test-helpers.js";
|
||||
import {
|
||||
expectWindowRetiredAfterFinal,
|
||||
expectWindowRetiredWithoutSummary,
|
||||
} from "./bot-message-dispatch.progress-window.test-helpers.js";
|
||||
import {
|
||||
allDeliveredReplyTexts,
|
||||
describeTelegramDispatch,
|
||||
createContext,
|
||||
createDirectSessionPayload,
|
||||
createReasoningStreamContext,
|
||||
createStatusReactionController,
|
||||
createTelegramDraftStream,
|
||||
deliverReplies,
|
||||
@@ -592,7 +589,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
// A tool-only window retires by repositioning in place (not delete + repost
|
||||
// — Discord parity), so clear() is never called on it.
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
expectDeliverRepliesParams({ replyToMode: "off" });
|
||||
// The final answer is SENT before the window retires: sending first keeps
|
||||
@@ -619,7 +615,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Terminal block answer");
|
||||
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
|
||||
expectDeliveredReply(0, { text: "Terminal block answer" });
|
||||
});
|
||||
|
||||
@@ -646,22 +641,11 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
expect.objectContaining({ text: expect.stringContaining("Exec") }),
|
||||
);
|
||||
expectDeliveredReply(0, { text: "Terminal block after tool" });
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
|
||||
});
|
||||
|
||||
function allDeliveredReplyTexts(): string[] {
|
||||
return deliverReplies.mock.calls.flatMap((call: unknown[]) =>
|
||||
((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map(
|
||||
(reply) => reply.text ?? "",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it("sends the final answer before retiring the progress window", async () => {
|
||||
// Edit-shrink anchor loss: shrinking the tall window to a one-line bar BEFORE
|
||||
// the final is sent breaks the client's at-bottom follow and drops the final
|
||||
// off screen. The final must be sent FIRST, then the window edited down.
|
||||
// Deliver first so removing the progress window cannot move the final off screen.
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
@@ -677,15 +661,11 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
// Final delivered first, then the window retires behind it.
|
||||
expectDeliveredReply(0, { text: "All done" });
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
|
||||
});
|
||||
|
||||
it("still collapses the window when the final answer send is skipped", async () => {
|
||||
// Failure path: if the final send skips/fails, the window must not be left
|
||||
// stale — it still collapses to the bar (once-guard already consumed).
|
||||
it("retires the progress window when the final answer send is skipped", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
deliverReplies.mockResolvedValue({ delivered: false });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
@@ -702,42 +682,12 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
// The bar still edits the window in place even though the final send failed.
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tallies reasoning bursts and tool calls into the collapse summary", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
// burst 1 → tool → burst 2 → tool, then a trailing burst flushed at the
|
||||
// summary: 3 thoughts, 2 tool calls.
|
||||
await replyOptions?.onReasoningStream?.({ text: "thinking a" });
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await replyOptions?.onReasoningStream?.({ text: "thinking b" });
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await replyOptions?.onReasoningStream?.({ text: "thinking c" });
|
||||
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
// Reasoning must resolve to "stream" so thoughts route into the progress
|
||||
// window — only window-streamed reasoning feeds the collapse summary.
|
||||
context: createReasoningStreamContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Done" });
|
||||
});
|
||||
|
||||
it("does not post a collapse summary when no progress draft started", async () => {
|
||||
it("delivers only the final answer when no progress draft started", async () => {
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
// No tools, thoughts, or notes — nothing collapses; just a final answer.
|
||||
await dispatcherOptions.deliver({ text: "Just an answer" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
@@ -748,12 +698,10 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
const texts = allDeliveredReplyTexts();
|
||||
expect(texts.some((text) => text.includes("⏱️"))).toBe(false);
|
||||
expect(texts).toContain("Just an answer");
|
||||
expect(allDeliveredReplyTexts()).toEqual(["Just an answer"]);
|
||||
});
|
||||
|
||||
it("does not post a collapse summary before an error final", async () => {
|
||||
it("delivers only the error final after tool progress", async () => {
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
@@ -772,7 +720,6 @@ describeTelegramDispatch("dispatchTelegramMessage draft-failures-progress", () =
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
const texts = allDeliveredReplyTexts();
|
||||
expect(texts.some((text) => text.includes("tool call · ⏱️"))).toBe(false);
|
||||
expect(allDeliveredReplyTexts()).toEqual(["Something went wrong"]);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-63
@@ -1,5 +1,5 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { expectWindowRetiredWithoutSummary } from "./bot-message-dispatch.progress-window.test-helpers.js";
|
||||
import { expectWindowRetiredAfterFinal } from "./bot-message-dispatch.progress-window.test-helpers.js";
|
||||
import {
|
||||
describeTelegramDispatch,
|
||||
allDeliveredReplyTexts,
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
} from "./bot-message-dispatch.test-harness.js";
|
||||
import type { TelegramMessageContext } from "./bot-message-dispatch.test-harness.js";
|
||||
|
||||
describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
describeTelegramDispatch("dispatchTelegramMessage progress-lifecycle", () => {
|
||||
it("keeps the progress window alive under /reasoning on so commentary and tools still stream", async () => {
|
||||
// /reasoning on removes only the 🧠 lane from the window; commentary, tool
|
||||
// lines, and the collapse bar must still stream (Discord parity). A prior
|
||||
// regression forced block streaming in progress mode, killing the window.
|
||||
// Durable reasoning removes only the reasoning lane, not commentary or tool progress.
|
||||
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
@@ -40,26 +38,16 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
telegramCfg: { streaming: { mode: "progress", progress: { commentary: true } } },
|
||||
});
|
||||
|
||||
// The window streamed (a preview was rendered) and collapsed into a bar
|
||||
// counting the note + tool — proof the window was not killed.
|
||||
expect(answerDraftStream.updatePreview).toHaveBeenCalled();
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
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.
|
||||
it("retires a tool-progress-only window after durable reasoning and a mid-turn boundary", async () => {
|
||||
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" },
|
||||
@@ -79,21 +67,12 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
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.
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
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");
|
||||
expect(allDeliveredReplyTexts()).toContain("Done");
|
||||
});
|
||||
|
||||
it("keeps a single stationary window when text follows durable reasoning (no mid-turn rotation)", async () => {
|
||||
// Single-message model (Discord parity): in progress mode the window is ONE
|
||||
// message edited through every lane handover — durable 🧠, interim answer
|
||||
// text — and edited into the bar only at collapse. It must NOT reposition or
|
||||
// rotate mid-turn (no new bubble, no delete), which is what caused the churn
|
||||
// and the on-off jump. Interim answer text does not render into the window.
|
||||
// Interim answer text must not rotate or render into the progress window.
|
||||
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
@@ -103,7 +82,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
{ text: "<think>hidden</think>", isReasoning: true },
|
||||
{ kind: "block" },
|
||||
);
|
||||
// Interim answer text mid-turn: must not spawn a new window bubble.
|
||||
await dispatcherOptions.deliver({ text: "Here is the answer" }, { kind: "block" });
|
||||
await dispatcherOptions.deliver({ text: "Here is the answer." }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
@@ -118,20 +96,11 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
// The one window message stays put through the whole turn: no mid-turn
|
||||
// reposition. It is retired once at end of turn, leaving the final answer as
|
||||
// the only surviving message.
|
||||
expect(answerDraftStream.rotateToNewMessageDeferringDelete).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
});
|
||||
|
||||
it("uses one stationary window message across a multi-boundary turn (commentary→tool→commentary→tool→final)", async () => {
|
||||
// Single-message model (Discord parity): ONE window message id is created
|
||||
// once and edited through every lane handover; it collapses into the bar in
|
||||
// place at the end. Zero deletes in the happy path; the final is posted
|
||||
// before the bar edit (task-9 order).
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
@@ -150,34 +119,22 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
telegramCfg: { streaming: { mode: "progress", progress: { commentary: true } } },
|
||||
});
|
||||
|
||||
// The SAME window message id is used the whole turn — no new bubble.
|
||||
const windowMessageIds = new Set(
|
||||
answerDraftStream.updatePreview.mock.calls
|
||||
.map(() => answerDraftStream.messageId())
|
||||
.filter((id) => id != null),
|
||||
);
|
||||
expect(windowMessageIds).toEqual(new Set([2001]));
|
||||
// The window was EDITED many times (once per lane change) ...
|
||||
expect(answerDraftStream.updatePreview.mock.calls.length).toBeGreaterThan(1);
|
||||
// A tool-only window is never deleted. It retires in place exactly once,
|
||||
// after the final send, so the tool log survives with no mid-turn churn.
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Final answer" });
|
||||
expect(requireInvocationOrder(deliverReplies, 0, "first reply delivery")).toBeLessThan(
|
||||
requireInvocationOrder(
|
||||
answerDraftStream.rotateToNewMessageDeferringDelete,
|
||||
0,
|
||||
"progress window retirement",
|
||||
),
|
||||
);
|
||||
expectWindowRetiredAfterFinal(answerDraftStream, deliverReplies);
|
||||
});
|
||||
|
||||
it("keeps Claude CLI pre-tool commentary after the progress window collapses", async () => {
|
||||
it("keeps CLI pre-tool commentary after the progress window retires", async () => {
|
||||
const markers = "Test markers: caribou-lampion-473, fromage-quantique, satellite-en-tricot";
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
expect(replyOptions?.commentaryPayloadsEnabled).toBe(true);
|
||||
@@ -202,7 +159,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expect(allDeliveredReplyTexts()).toEqual([markers, "TEST DONE"]);
|
||||
});
|
||||
|
||||
@@ -235,15 +191,13 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
),
|
||||
];
|
||||
expect(windowTexts.some((text) => text.includes("Interim answer prose"))).toBe(false);
|
||||
// The final answer is delivered below the collapsed window.
|
||||
const delivered = allDeliveredReplyTexts();
|
||||
expect(delivered).toContain("The real final answer.");
|
||||
expect(delivered.some((text) => text.includes("Interim answer prose"))).toBe(false);
|
||||
});
|
||||
|
||||
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.
|
||||
// The durable verbose lane owns tool messages, so the progress window must not duplicate them.
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
@@ -260,12 +214,8 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
// No tool line ever rendered to the window (verbose owns it durably), so the
|
||||
// window never streamed and there is no collapse bar to count it.
|
||||
expect(answerDraftStream.updatePreview).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.finalizeToPreview).not.toHaveBeenCalled();
|
||||
const texts = allDeliveredReplyTexts();
|
||||
expect(texts.some((text) => text.includes("tool call"))).toBe(false);
|
||||
expect(allDeliveredReplyTexts()).toEqual(["Done"]);
|
||||
});
|
||||
|
||||
it("replaces Telegram command progress items with matching command output", async () => {
|
||||
@@ -340,8 +290,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-summary", () => {
|
||||
).toBeLessThan(
|
||||
requireInvocationOrder(answerDraftStream.update, 0, "first answer draft update"),
|
||||
);
|
||||
// The window retires at end of turn; the final answer posts fresh below it.
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
});
|
||||
|
||||
@@ -346,9 +346,7 @@ describeTelegramDispatch("dispatchTelegramMessage progress-rendering", () => {
|
||||
"<b>Shelling</b>\n<b>🔎 Web Search</b> <code>docs lookup</code>\n<b>Update</b> <code>tests passed</code>",
|
||||
),
|
||||
);
|
||||
// A tool-progress-only window with nothing to summarize is torn down via the
|
||||
// deferred-delete reposition (new content first, delete later), not a bare
|
||||
// immediate clear/delete or forceNewMessage.
|
||||
// Retire a tool-progress-only window by repositioning, with its delete deferred.
|
||||
expect(draftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
isChannelPartialDeliveryError,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { expect, it } from "vitest";
|
||||
import { expectWindowRetiredWithoutSummary } from "./bot-message-dispatch.progress-window.test-helpers.js";
|
||||
import {
|
||||
appendAssistantMirrorMessageByIdentity,
|
||||
type DispatchReplyWithBufferedBlockDispatcherArgs,
|
||||
@@ -52,7 +51,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
|
||||
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
|
||||
);
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
});
|
||||
|
||||
@@ -83,7 +81,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
|
||||
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
|
||||
);
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
});
|
||||
|
||||
@@ -118,12 +115,11 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
|
||||
telegramProgressPreview("Shelling\n\n🛠️ Exec", "<b>Shelling</b>\n<b>🛠️ Exec</b>"),
|
||||
);
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
});
|
||||
|
||||
it("uses the transcript final when progress-mode final text is truncated", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
@@ -149,7 +145,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
expectWindowRetiredWithoutSummary(answerDraftStream);
|
||||
expectDeliveredReply(0, { text: fullAnswer });
|
||||
});
|
||||
|
||||
@@ -447,7 +442,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
expect(rollingPreview?.text).toContain(`command-${index}`);
|
||||
}
|
||||
expectDeliveredReply(0, { text: "Done" });
|
||||
expectWindowRetiredWithoutSummary(draftStream);
|
||||
});
|
||||
|
||||
it("renders command status without command output in Telegram progress draft previews", async () => {
|
||||
@@ -587,7 +581,6 @@ describeTelegramDispatch("dispatchTelegramMessage progress-updates", () => {
|
||||
"<b>Shelling</b>\n<b>🧠 Thinking… (~200 tokens)</b>",
|
||||
),
|
||||
);
|
||||
expectWindowRetiredWithoutSummary(draftStream);
|
||||
expectDeliveredReply(0, { text: "Done" });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,19 +4,6 @@ import { requireInvocationOrder } from "./bot-message-dispatch.test-harness.js";
|
||||
|
||||
type OrderedMock = { mock: { invocationCallOrder: number[] } };
|
||||
|
||||
/**
|
||||
* Turn end retires the progress window: no synthesized activity digest is ever
|
||||
* written back into it.
|
||||
*/
|
||||
export function expectWindowRetiredWithoutSummary(stream: {
|
||||
finalizeToPreview: { mock: { calls: unknown[][] } };
|
||||
}) {
|
||||
const digests = stream.finalizeToPreview.mock.calls
|
||||
.map((call) => (call[0] as { text?: string } | undefined)?.text ?? "")
|
||||
.filter((text) => text.includes("⏱️"));
|
||||
expect(digests).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retirement lands after the final, so shrinking the window above it never
|
||||
* pushes the final off the anchored viewport. Text windows clear; tool-only
|
||||
|
||||
@@ -346,7 +346,6 @@ export const dispatchTelegramMessage = async (
|
||||
const progressState = createProgressState(
|
||||
turnConfig,
|
||||
draftState,
|
||||
() => turn,
|
||||
async () => await prepareAnswerLaneForToolProgress(turn),
|
||||
);
|
||||
const deliveryState = createDeliveryState({ ...turnConfig, lanes: draftState.lanes }, () => turn);
|
||||
@@ -394,8 +393,7 @@ export const dispatchTelegramMessage = async (
|
||||
turn.dispatchError = err;
|
||||
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
||||
} finally {
|
||||
// Terminal order: stop producers, drain queued drafts, materialize accepted text,
|
||||
// clean previews, then collapse the progress window.
|
||||
// Stop producers before draining drafts, finalizing accepted text, and cleaning previews.
|
||||
turn.progressCompositor.cancel();
|
||||
await waitForDraftEvents(turn);
|
||||
try {
|
||||
|
||||
@@ -188,10 +188,8 @@ export type TelegramDraftStateSlice = {
|
||||
};
|
||||
|
||||
export type TelegramProgressStateSlice = {
|
||||
draftEverRendered: boolean;
|
||||
finalAnswerDeliveryStarted: boolean;
|
||||
finalAnswerDelivered: boolean;
|
||||
sawProgressFinal: boolean;
|
||||
verboseProgressActive: () => boolean;
|
||||
progressCompositor: TelegramProgressCompositor;
|
||||
commentaryProgressEnabled: boolean;
|
||||
|
||||
@@ -18,9 +18,6 @@ type TestDraftStream = {
|
||||
clear: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
stop: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
discard: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
finalizeToPreview: ReturnType<
|
||||
typeof vi.fn<(preview: TelegramDraftPreview) => Promise<number | undefined>>
|
||||
>;
|
||||
forceNewMessage: ReturnType<typeof vi.fn<() => void>>;
|
||||
rotateToNewMessageDeferringDelete: ReturnType<typeof vi.fn<() => number | undefined>>;
|
||||
sendMayHaveLanded: ReturnType<typeof vi.fn<() => boolean>>;
|
||||
@@ -88,14 +85,6 @@ export function createTestDraftStream(params?: {
|
||||
}
|
||||
await params?.onDiscard?.();
|
||||
}),
|
||||
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
|
||||
if (messageId == null) {
|
||||
return undefined;
|
||||
}
|
||||
lastDeliveredText = preview.text.trimEnd();
|
||||
stopped = true;
|
||||
return messageId;
|
||||
}),
|
||||
forceNewMessage: vi.fn().mockImplementation(() => {
|
||||
stopped = false;
|
||||
if (params?.clearMessageIdOnForceNew) {
|
||||
@@ -160,13 +149,6 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
discard: vi.fn().mockResolvedValue(undefined),
|
||||
finalizeToPreview: vi.fn().mockImplementation(async (preview: TelegramDraftPreview) => {
|
||||
if (activeMessageId == null) {
|
||||
return undefined;
|
||||
}
|
||||
lastDeliveredText = preview.text.trimEnd();
|
||||
return activeMessageId;
|
||||
}),
|
||||
forceNewMessage: vi.fn().mockImplementation(() => {
|
||||
activeMessageId = undefined;
|
||||
}),
|
||||
|
||||
@@ -460,99 +460,6 @@ describe("createTelegramDraftStream", () => {
|
||||
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "see https://example.com now");
|
||||
});
|
||||
|
||||
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("finalizeToPreview returns undefined when the in-place collapse edit does not apply", async () => {
|
||||
// Red-team F2: a flood-wait (429) on the collapse edit makes the underlying
|
||||
// send return false without applying. finalizeToPreview must report that as
|
||||
// "not collapsed in place" (undefined) so the dispatch falls back to posting
|
||||
// a durable bar — otherwise it assumes success, clears state, posts no bar,
|
||||
// and the tall window is left on screen.
|
||||
const api = createMockDraftApi();
|
||||
api.editMessageText.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error("Call to 'editMessageText' failed! (429: Too Many Requests: retry after 5)"),
|
||||
{ error_code: 429, parameters: { retry_after: 5 } },
|
||||
),
|
||||
);
|
||||
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).toBeUndefined();
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(1);
|
||||
// The live window is NOT deleted (the caller posts the bar below it instead).
|
||||
expect(api.deleteMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replay a rejected pending edit after collapse fallback", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const retryableEditError = () =>
|
||||
Object.assign(new Error("429: retry after 1"), {
|
||||
error_code: 429,
|
||||
parameters: { retry_after: 1 },
|
||||
});
|
||||
const stream = createDraftStream(api, { thread: { id: 42, scope: "dm" } });
|
||||
|
||||
stream.update("working");
|
||||
await stream.flush();
|
||||
api.editMessageText
|
||||
.mockRejectedValueOnce(retryableEditError())
|
||||
.mockRejectedValueOnce(retryableEditError());
|
||||
stream.update("pending update");
|
||||
const messageId = await stream.finalizeToPreview({ text: "🛠️ 1 tool call · ⏱️ 1s" });
|
||||
|
||||
expect(messageId).toBeUndefined();
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
await stream.stop();
|
||||
await stream.flush();
|
||||
expect(api.editMessageText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deletes message preview on clear after finalization", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -66,13 +66,6 @@ export type TelegramDraftStream = {
|
||||
remainingFinalContent?: () => TelegramDraftMessageSnapshot | undefined;
|
||||
/** True while a pending or visible draft owns a first/batched reply target. */
|
||||
hasConsumedReplyTarget?: () => boolean;
|
||||
/**
|
||||
* Collapse the preview in place: edit the existing window message so its
|
||||
* content becomes `preview`, then stop without deleting. Used at end-of-turn
|
||||
* so the streaming window becomes the summary bar (no delete + repost, which
|
||||
* scroll-jumps the client). Returns the message id if the edit landed.
|
||||
*/
|
||||
finalizeToPreview: (preview: TelegramDraftPreview) => Promise<number | undefined>;
|
||||
/** Reset internal state so the next update creates a new message instead of editing. */
|
||||
forceNewMessage: () => void;
|
||||
/**
|
||||
@@ -918,61 +911,6 @@ export function createTelegramDraftStream(params: {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const finalizeToPreview = async (preview: TelegramDraftPreview): Promise<number | undefined> => {
|
||||
const finalizeGeneration = generation;
|
||||
const text = preview.text.trimEnd();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
// Settle pending updates so we edit the real, current window message.
|
||||
streamState.final = true;
|
||||
await flush();
|
||||
if (generation !== finalizeGeneration) {
|
||||
return undefined;
|
||||
}
|
||||
// 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()) {
|
||||
const materialized = await sendOrEditStreamMessage(pending);
|
||||
if (generation !== finalizeGeneration) {
|
||||
return undefined;
|
||||
}
|
||||
if (materialized) {
|
||||
loop.resetPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// Collapse takes ownership of the live window. A stale throttled edit must
|
||||
// not replay after either this edit or the caller's durable fallback.
|
||||
loop.resetPending();
|
||||
// Replace the whole message with the bar line.
|
||||
finalPagePlan = undefined;
|
||||
lastSentPreviewKey = "";
|
||||
lastRequestedText = text;
|
||||
lastRequestedPreview = { ...preview, text };
|
||||
// The edit can fail to apply (flood-wait 429 or a terminal error both return
|
||||
// false). Report that as "not collapsed in place" so the caller falls back to
|
||||
// posting a durable bar instead of assuming the tall window became the bar.
|
||||
const edited = await sendOrEditStreamMessage(text);
|
||||
if (generation !== finalizeGeneration) {
|
||||
return undefined;
|
||||
}
|
||||
streamState.stopped = true;
|
||||
observeCurrentProviderMessage();
|
||||
await drainProviderMessageObservations();
|
||||
return edited ? streamMessageId : undefined;
|
||||
};
|
||||
|
||||
params.log?.(`telegram stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
|
||||
|
||||
return {
|
||||
@@ -993,7 +931,6 @@ export function createTelegramDraftStream(params: {
|
||||
},
|
||||
remainingFinalContent,
|
||||
hasConsumedReplyTarget: () => replyTargetState.kind !== "available",
|
||||
finalizeToPreview,
|
||||
forceNewMessage: () => resetStreamToNewMessage(false, true),
|
||||
rotateToNewMessageDeferringDelete,
|
||||
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
|
||||
|
||||
Reference in New Issue
Block a user