mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 07:30:21 +00:00
fix: stabilize Telegram draft boundaries and suppress NO_REPLY lead leaks (#33169)
* fix: stabilize telegram draft stream message boundaries * fix: suppress NO_REPLY lead-fragment leaks * fix: keep underscore guard for non-NO_REPLY prefixes * fix: skip assistant-start rotation only after real lane rotation * fix: preserve finalized state when pre-rotation does not force * fix: reset finalized preview state on message-start boundary * fix: document Telegram draft boundary + NO_REPLY reliability updates (#33169) (thanks @obviyus)
This commit is contained in:
@@ -225,16 +225,20 @@ export const dispatchTelegramMessage = async ({
|
||||
stream,
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
previewRevisionBaseline: stream?.previewRevision?.() ?? 0,
|
||||
};
|
||||
};
|
||||
const lanes: Record<LaneName, DraftLaneState> = {
|
||||
answer: createDraftLane("answer", canStreamAnswerDraft),
|
||||
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
|
||||
};
|
||||
const finalizedPreviewByLane: Record<LaneName, boolean> = {
|
||||
answer: false,
|
||||
reasoning: false,
|
||||
};
|
||||
const answerLane = lanes.answer;
|
||||
const reasoningLane = lanes.reasoning;
|
||||
let splitReasoningOnNextStream = false;
|
||||
let skipNextAnswerMessageStartRotation = false;
|
||||
const reasoningStepState = createTelegramReasoningStepState();
|
||||
type SplitLaneSegment = { lane: LaneName; text: string };
|
||||
type SplitLaneSegmentsResult = {
|
||||
@@ -260,7 +264,29 @@ export const dispatchTelegramMessage = async ({
|
||||
const resetDraftLaneState = (lane: DraftLaneState) => {
|
||||
lane.lastPartialText = "";
|
||||
lane.hasStreamedMessage = false;
|
||||
lane.previewRevisionBaseline = lane.stream?.previewRevision?.() ?? lane.previewRevisionBaseline;
|
||||
};
|
||||
const rotateAnswerLaneForNewAssistantMessage = () => {
|
||||
let didForceNewMessage = false;
|
||||
if (answerLane.hasStreamedMessage) {
|
||||
const previewMessageId = answerLane.stream?.messageId();
|
||||
// Only archive previews that still need a matching final text update.
|
||||
// Once a preview has already been finalized, archiving it here causes
|
||||
// cleanup to delete a user-visible final message on later media-only turns.
|
||||
if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) {
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: previewMessageId,
|
||||
textSnapshot: answerLane.lastPartialText,
|
||||
});
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
didForceNewMessage = true;
|
||||
}
|
||||
resetDraftLaneState(answerLane);
|
||||
if (didForceNewMessage) {
|
||||
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
|
||||
finalizedPreviewByLane.answer = false;
|
||||
}
|
||||
return didForceNewMessage;
|
||||
};
|
||||
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
|
||||
const laneStream = lane.stream;
|
||||
@@ -287,6 +313,13 @@ export const dispatchTelegramMessage = async ({
|
||||
};
|
||||
const ingestDraftLaneSegments = (text: string | undefined) => {
|
||||
const split = splitTextIntoLaneSegments(text);
|
||||
const hasAnswerSegment = split.segments.some((segment) => segment.lane === "answer");
|
||||
if (hasAnswerSegment && finalizedPreviewByLane.answer) {
|
||||
// Some providers can emit the first partial of a new assistant message before
|
||||
// onAssistantMessageStart() arrives. Rotate preemptively so we do not edit
|
||||
// the previously finalized preview message with the next message's text.
|
||||
skipNextAnswerMessageStartRotation = rotateAnswerLaneForNewAssistantMessage();
|
||||
}
|
||||
for (const segment of split.segments) {
|
||||
if (segment.lane === "reasoning") {
|
||||
reasoningStepState.noteReasoningHint();
|
||||
@@ -376,10 +409,6 @@ export const dispatchTelegramMessage = async ({
|
||||
? ctxPayload.ReplyToBody.trim() || undefined
|
||||
: undefined;
|
||||
const deliveryState = createLaneDeliveryStateTracker();
|
||||
const finalizedPreviewByLane: Record<LaneName, boolean> = {
|
||||
answer: false,
|
||||
reasoning: false,
|
||||
};
|
||||
const clearGroupHistory = () => {
|
||||
if (isGroup && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
||||
@@ -599,21 +628,16 @@ export const dispatchTelegramMessage = async ({
|
||||
onAssistantMessageStart: answerLane.stream
|
||||
? async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
if (answerLane.hasStreamedMessage) {
|
||||
const previewMessageId = answerLane.stream?.messageId();
|
||||
// Only archive previews that still need a matching final text update.
|
||||
// Once a preview has already been finalized, archiving it here causes
|
||||
// cleanup to delete a user-visible final message on later media-only turns.
|
||||
if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) {
|
||||
archivedAnswerPreviews.push({
|
||||
messageId: previewMessageId,
|
||||
textSnapshot: answerLane.lastPartialText,
|
||||
});
|
||||
}
|
||||
answerLane.stream?.forceNewMessage();
|
||||
if (skipNextAnswerMessageStartRotation) {
|
||||
skipNextAnswerMessageStartRotation = false;
|
||||
finalizedPreviewByLane.answer = false;
|
||||
return;
|
||||
}
|
||||
resetDraftLaneState(answerLane);
|
||||
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
|
||||
rotateAnswerLaneForNewAssistantMessage();
|
||||
// Message-start is an explicit assistant-message boundary.
|
||||
// Even when no forceNewMessage happened (e.g. prior answer had no
|
||||
// streamed partials), the next partial belongs to a fresh lifecycle
|
||||
// and must not trigger late pre-rotation mid-message.
|
||||
finalizedPreviewByLane.answer = false;
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user