mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 00:21:38 +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:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
|
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
|
||||||
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
|
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
|
||||||
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
|
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
|
||||||
|
- Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
|
||||||
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
|
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
|
||||||
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
|
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
|
||||||
- Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
|
- Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
|||||||
shouldType: false,
|
shouldType: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
partials: ["NO_", "NO_RE", "NO_REPLY"],
|
partials: ["NO", "NO_", "NO_RE", "NO_REPLY"],
|
||||||
finalText: "NO_REPLY",
|
finalText: "NO_REPLY",
|
||||||
expectedForwarded: [] as string[],
|
expectedForwarded: [] as string[],
|
||||||
shouldType: false,
|
shouldType: false,
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ describe("stripSilentToken", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("isSilentReplyPrefixText", () => {
|
describe("isSilentReplyPrefixText", () => {
|
||||||
it("matches uppercase underscore prefixes", () => {
|
it("matches uppercase token lead fragments", () => {
|
||||||
|
expect(isSilentReplyPrefixText("NO")).toBe(true);
|
||||||
expect(isSilentReplyPrefixText("NO_")).toBe(true);
|
expect(isSilentReplyPrefixText("NO_")).toBe(true);
|
||||||
expect(isSilentReplyPrefixText("NO_RE")).toBe(true);
|
expect(isSilentReplyPrefixText("NO_RE")).toBe(true);
|
||||||
expect(isSilentReplyPrefixText("NO_REPLY")).toBe(true);
|
expect(isSilentReplyPrefixText("NO_REPLY")).toBe(true);
|
||||||
@@ -84,9 +85,17 @@ describe("isSilentReplyPrefixText", () => {
|
|||||||
it("rejects ambiguous natural-language prefixes", () => {
|
it("rejects ambiguous natural-language prefixes", () => {
|
||||||
expect(isSilentReplyPrefixText("N")).toBe(false);
|
expect(isSilentReplyPrefixText("N")).toBe(false);
|
||||||
expect(isSilentReplyPrefixText("No")).toBe(false);
|
expect(isSilentReplyPrefixText("No")).toBe(false);
|
||||||
|
expect(isSilentReplyPrefixText("no")).toBe(false);
|
||||||
expect(isSilentReplyPrefixText("Hello")).toBe(false);
|
expect(isSilentReplyPrefixText("Hello")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps underscore guard for non-NO_REPLY tokens", () => {
|
||||||
|
expect(isSilentReplyPrefixText("HE", "HEARTBEAT_OK")).toBe(false);
|
||||||
|
expect(isSilentReplyPrefixText("HEART", "HEARTBEAT_OK")).toBe(false);
|
||||||
|
expect(isSilentReplyPrefixText("HEARTBEAT", "HEARTBEAT_OK")).toBe(false);
|
||||||
|
expect(isSilentReplyPrefixText("HEARTBEAT_", "HEARTBEAT_OK")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects non-prefixes and mixed characters", () => {
|
it("rejects non-prefixes and mixed characters", () => {
|
||||||
expect(isSilentReplyPrefixText("NO_X")).toBe(false);
|
expect(isSilentReplyPrefixText("NO_X")).toBe(false);
|
||||||
expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false);
|
expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false);
|
||||||
|
|||||||
@@ -56,15 +56,34 @@ export function isSilentReplyPrefixText(
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const normalized = text.trimStart().toUpperCase();
|
const trimmed = text.trimStart();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Guard against suppressing natural-language "No..." text while still
|
||||||
|
// catching uppercase lead fragments like "NO" from streamed NO_REPLY.
|
||||||
|
if (trimmed !== trimmed.toUpperCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = trimmed.toUpperCase();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!normalized.includes("_")) {
|
if (normalized.length < 2) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (/[^A-Z_]/.test(normalized)) {
|
if (/[^A-Z_]/.test(normalized)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return token.toUpperCase().startsWith(normalized);
|
const tokenUpper = token.toUpperCase();
|
||||||
|
if (!tokenUpper.startsWith(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalized.includes("_")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Keep underscore guard for generic tokens to avoid suppressing unrelated
|
||||||
|
// uppercase words (e.g. HEART/HE with HEARTBEAT_OK). Only allow bare "NO"
|
||||||
|
// because NO_REPLY streaming can transiently emit that fragment.
|
||||||
|
return tokenUpper === SILENT_REPLY_TOKEN && normalized === "NO";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,6 +444,133 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rotates before a late second-message partial so finalized preview is not overwritten", async () => {
|
||||||
|
const answerDraftStream = createSequencedDraftStream(1001);
|
||||||
|
const reasoningDraftStream = createDraftStream();
|
||||||
|
createTelegramDraftStream
|
||||||
|
.mockImplementationOnce(() => answerDraftStream)
|
||||||
|
.mockImplementationOnce(() => reasoningDraftStream);
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||||
|
async ({ dispatcherOptions, replyOptions }) => {
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message A partial" });
|
||||||
|
await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" });
|
||||||
|
// Simulate provider ordering bug: first chunk arrives before message-start callback.
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B early" });
|
||||||
|
await replyOptions?.onAssistantMessageStart?.();
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B partial" });
|
||||||
|
await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" });
|
||||||
|
return { queuedFinal: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
|
||||||
|
|
||||||
|
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
|
||||||
|
|
||||||
|
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B early");
|
||||||
|
const boundaryRotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
|
||||||
|
const secondUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
|
||||||
|
expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder);
|
||||||
|
expect(editMessageTelegram).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
123,
|
||||||
|
1001,
|
||||||
|
"Message A final",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(editMessageTelegram).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
123,
|
||||||
|
1002,
|
||||||
|
"Message B final",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not skip message-start rotation when pre-rotation did not force a new message", async () => {
|
||||||
|
const answerDraftStream = createSequencedDraftStream(1002);
|
||||||
|
answerDraftStream.setMessageId(1001);
|
||||||
|
const reasoningDraftStream = createDraftStream();
|
||||||
|
createTelegramDraftStream
|
||||||
|
.mockImplementationOnce(() => answerDraftStream)
|
||||||
|
.mockImplementationOnce(() => reasoningDraftStream);
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||||
|
async ({ dispatcherOptions, replyOptions }) => {
|
||||||
|
// First message has only final text (no streamed partials), so answer lane
|
||||||
|
// reaches finalized state with hasStreamedMessage still false.
|
||||||
|
await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" });
|
||||||
|
// Provider ordering bug: next message partial arrives before message-start.
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B early" });
|
||||||
|
await replyOptions?.onAssistantMessageStart?.();
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B partial" });
|
||||||
|
await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" });
|
||||||
|
return { queuedFinal: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
|
||||||
|
const bot = createBot();
|
||||||
|
|
||||||
|
await dispatchWithContext({ context: createContext(), streamMode: "partial", bot });
|
||||||
|
|
||||||
|
// Early pre-rotation could not force (no streamed partials yet), so the
|
||||||
|
// real assistant message_start must still rotate once.
|
||||||
|
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Message B early");
|
||||||
|
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B partial");
|
||||||
|
const earlyUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
|
||||||
|
const boundaryRotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
|
||||||
|
const secondUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
|
||||||
|
expect(earlyUpdateOrder).toBeLessThan(boundaryRotationOrder);
|
||||||
|
expect(boundaryRotationOrder).toBeLessThan(secondUpdateOrder);
|
||||||
|
expect(editMessageTelegram).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
123,
|
||||||
|
1001,
|
||||||
|
"Message A final",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(editMessageTelegram).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
123,
|
||||||
|
1002,
|
||||||
|
"Message B final",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect((bot.api.deleteMessage as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trigger late pre-rotation mid-message after an explicit assistant message start", async () => {
|
||||||
|
const answerDraftStream = createDraftStream(1001);
|
||||||
|
const reasoningDraftStream = createDraftStream();
|
||||||
|
createTelegramDraftStream
|
||||||
|
.mockImplementationOnce(() => answerDraftStream)
|
||||||
|
.mockImplementationOnce(() => reasoningDraftStream);
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||||
|
async ({ dispatcherOptions, replyOptions }) => {
|
||||||
|
// Message A finalizes without streamed partials.
|
||||||
|
await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" });
|
||||||
|
// Message B starts normally before partials.
|
||||||
|
await replyOptions?.onAssistantMessageStart?.();
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B first chunk" });
|
||||||
|
await replyOptions?.onPartialReply?.({ text: "Message B second chunk" });
|
||||||
|
await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" });
|
||||||
|
return { queuedFinal: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
|
||||||
|
|
||||||
|
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
|
||||||
|
|
||||||
|
// The explicit message_start boundary must clear finalized state so
|
||||||
|
// same-message partials do not force a new preview mid-stream.
|
||||||
|
expect(answerDraftStream.forceNewMessage).not.toHaveBeenCalled();
|
||||||
|
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Message B first chunk");
|
||||||
|
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Message B second chunk");
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes multi-message assistant stream to matching preview messages in order", async () => {
|
it("finalizes multi-message assistant stream to matching preview messages in order", async () => {
|
||||||
const answerDraftStream = createSequencedDraftStream(1001);
|
const answerDraftStream = createSequencedDraftStream(1001);
|
||||||
const reasoningDraftStream = createDraftStream();
|
const reasoningDraftStream = createDraftStream();
|
||||||
|
|||||||
@@ -225,16 +225,20 @@ export const dispatchTelegramMessage = async ({
|
|||||||
stream,
|
stream,
|
||||||
lastPartialText: "",
|
lastPartialText: "",
|
||||||
hasStreamedMessage: false,
|
hasStreamedMessage: false,
|
||||||
previewRevisionBaseline: stream?.previewRevision?.() ?? 0,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const lanes: Record<LaneName, DraftLaneState> = {
|
const lanes: Record<LaneName, DraftLaneState> = {
|
||||||
answer: createDraftLane("answer", canStreamAnswerDraft),
|
answer: createDraftLane("answer", canStreamAnswerDraft),
|
||||||
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
|
reasoning: createDraftLane("reasoning", canStreamReasoningDraft),
|
||||||
};
|
};
|
||||||
|
const finalizedPreviewByLane: Record<LaneName, boolean> = {
|
||||||
|
answer: false,
|
||||||
|
reasoning: false,
|
||||||
|
};
|
||||||
const answerLane = lanes.answer;
|
const answerLane = lanes.answer;
|
||||||
const reasoningLane = lanes.reasoning;
|
const reasoningLane = lanes.reasoning;
|
||||||
let splitReasoningOnNextStream = false;
|
let splitReasoningOnNextStream = false;
|
||||||
|
let skipNextAnswerMessageStartRotation = false;
|
||||||
const reasoningStepState = createTelegramReasoningStepState();
|
const reasoningStepState = createTelegramReasoningStepState();
|
||||||
type SplitLaneSegment = { lane: LaneName; text: string };
|
type SplitLaneSegment = { lane: LaneName; text: string };
|
||||||
type SplitLaneSegmentsResult = {
|
type SplitLaneSegmentsResult = {
|
||||||
@@ -260,7 +264,29 @@ export const dispatchTelegramMessage = async ({
|
|||||||
const resetDraftLaneState = (lane: DraftLaneState) => {
|
const resetDraftLaneState = (lane: DraftLaneState) => {
|
||||||
lane.lastPartialText = "";
|
lane.lastPartialText = "";
|
||||||
lane.hasStreamedMessage = false;
|
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 updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
|
||||||
const laneStream = lane.stream;
|
const laneStream = lane.stream;
|
||||||
@@ -287,6 +313,13 @@ export const dispatchTelegramMessage = async ({
|
|||||||
};
|
};
|
||||||
const ingestDraftLaneSegments = (text: string | undefined) => {
|
const ingestDraftLaneSegments = (text: string | undefined) => {
|
||||||
const split = splitTextIntoLaneSegments(text);
|
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) {
|
for (const segment of split.segments) {
|
||||||
if (segment.lane === "reasoning") {
|
if (segment.lane === "reasoning") {
|
||||||
reasoningStepState.noteReasoningHint();
|
reasoningStepState.noteReasoningHint();
|
||||||
@@ -376,10 +409,6 @@ export const dispatchTelegramMessage = async ({
|
|||||||
? ctxPayload.ReplyToBody.trim() || undefined
|
? ctxPayload.ReplyToBody.trim() || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const deliveryState = createLaneDeliveryStateTracker();
|
const deliveryState = createLaneDeliveryStateTracker();
|
||||||
const finalizedPreviewByLane: Record<LaneName, boolean> = {
|
|
||||||
answer: false,
|
|
||||||
reasoning: false,
|
|
||||||
};
|
|
||||||
const clearGroupHistory = () => {
|
const clearGroupHistory = () => {
|
||||||
if (isGroup && historyKey) {
|
if (isGroup && historyKey) {
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
||||||
@@ -599,21 +628,16 @@ export const dispatchTelegramMessage = async ({
|
|||||||
onAssistantMessageStart: answerLane.stream
|
onAssistantMessageStart: answerLane.stream
|
||||||
? async () => {
|
? async () => {
|
||||||
reasoningStepState.resetForNextStep();
|
reasoningStepState.resetForNextStep();
|
||||||
if (answerLane.hasStreamedMessage) {
|
if (skipNextAnswerMessageStartRotation) {
|
||||||
const previewMessageId = answerLane.stream?.messageId();
|
skipNextAnswerMessageStartRotation = false;
|
||||||
// Only archive previews that still need a matching final text update.
|
finalizedPreviewByLane.answer = false;
|
||||||
// Once a preview has already been finalized, archiving it here causes
|
return;
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
resetDraftLaneState(answerLane);
|
rotateAnswerLaneForNewAssistantMessage();
|
||||||
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
|
// 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;
|
finalizedPreviewByLane.answer = false;
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function createHarness(params?: {
|
|||||||
answerStream?: DraftLaneState["stream"];
|
answerStream?: DraftLaneState["stream"];
|
||||||
answerHasStreamedMessage?: boolean;
|
answerHasStreamedMessage?: boolean;
|
||||||
answerLastPartialText?: string;
|
answerLastPartialText?: string;
|
||||||
answerPreviewRevisionBaseline?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const answer =
|
const answer =
|
||||||
params?.answerStream ?? createTestDraftStream({ messageId: params?.answerMessageId });
|
params?.answerStream ?? createTestDraftStream({ messageId: params?.answerMessageId });
|
||||||
@@ -20,13 +19,11 @@ function createHarness(params?: {
|
|||||||
stream: answer,
|
stream: answer,
|
||||||
lastPartialText: params?.answerLastPartialText ?? "",
|
lastPartialText: params?.answerLastPartialText ?? "",
|
||||||
hasStreamedMessage: params?.answerHasStreamedMessage ?? false,
|
hasStreamedMessage: params?.answerHasStreamedMessage ?? false,
|
||||||
previewRevisionBaseline: params?.answerPreviewRevisionBaseline ?? 0,
|
|
||||||
},
|
},
|
||||||
reasoning: {
|
reasoning: {
|
||||||
stream: reasoning as DraftLaneState["stream"],
|
stream: reasoning as DraftLaneState["stream"],
|
||||||
lastPartialText: "",
|
lastPartialText: "",
|
||||||
hasStreamedMessage: false,
|
hasStreamedMessage: false,
|
||||||
previewRevisionBaseline: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const sendPayload = vi.fn().mockResolvedValue(true);
|
const sendPayload = vi.fn().mockResolvedValue(true);
|
||||||
@@ -212,10 +209,8 @@ describe("createLaneTextDeliverer", () => {
|
|||||||
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
|
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats unchanged DM draft final text as already finalized", async () => {
|
it("sends a final message after DM draft streaming even when text is unchanged", async () => {
|
||||||
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
||||||
answerStream.previewRevision.mockReturnValue(7);
|
|
||||||
answerStream.lastDeliveredText.mockReturnValue("Hello final");
|
|
||||||
answerStream.update.mockImplementation(() => {});
|
answerStream.update.mockImplementation(() => {});
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
answerStream: answerStream as DraftLaneState["stream"],
|
answerStream: answerStream as DraftLaneState["stream"],
|
||||||
@@ -230,76 +225,19 @@ describe("createLaneTextDeliverer", () => {
|
|||||||
infoKind: "final",
|
infoKind: "final",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe("preview-finalized");
|
|
||||||
expect(harness.flushDraftLane).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
|
||||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back once when DM draft finalization emits no update", async () => {
|
|
||||||
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
|
||||||
answerStream.previewRevision.mockReturnValue(3);
|
|
||||||
answerStream.update.mockImplementation(() => {});
|
|
||||||
const harness = createHarness({
|
|
||||||
answerStream: answerStream as DraftLaneState["stream"],
|
|
||||||
answerHasStreamedMessage: true,
|
|
||||||
answerLastPartialText: "Partial",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await harness.deliverLaneText({
|
|
||||||
laneName: "answer",
|
|
||||||
text: "Final answer",
|
|
||||||
payload: { text: "Final answer" },
|
|
||||||
infoKind: "final",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("sent");
|
expect(result).toBe("sent");
|
||||||
expect(harness.flushDraftLane).toHaveBeenCalledTimes(1);
|
expect(harness.flushDraftLane).toHaveBeenCalled();
|
||||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
expect(harness.stopDraftLane).toHaveBeenCalled();
|
||||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ text: "Final answer" }),
|
|
||||||
);
|
|
||||||
expect(harness.markDelivered).not.toHaveBeenCalled();
|
|
||||||
expect(harness.log).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("draft final text not emitted"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back when unchanged final text has no emitted draft preview in current lane", async () => {
|
|
||||||
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
|
||||||
answerStream.previewRevision.mockReturnValue(7);
|
|
||||||
answerStream.update.mockImplementation(() => {});
|
|
||||||
const harness = createHarness({
|
|
||||||
answerStream: answerStream as DraftLaneState["stream"],
|
|
||||||
answerHasStreamedMessage: true,
|
|
||||||
answerLastPartialText: "Hello final",
|
|
||||||
answerPreviewRevisionBaseline: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await harness.deliverLaneText({
|
|
||||||
laneName: "answer",
|
|
||||||
text: "Hello final",
|
|
||||||
payload: { text: "Hello final" },
|
|
||||||
infoKind: "final",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("sent");
|
|
||||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ text: "Hello final" }),
|
expect.objectContaining({ text: "Hello final" }),
|
||||||
);
|
);
|
||||||
expect(harness.markDelivered).not.toHaveBeenCalled();
|
expect(harness.markDelivered).not.toHaveBeenCalled();
|
||||||
expect(harness.log).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("draft final text not emitted"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back when revision advances but final text was not emitted", async () => {
|
it("sends a final message after DM draft streaming when revision changes", async () => {
|
||||||
let previewRevision = 7;
|
let previewRevision = 3;
|
||||||
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
const answerStream = createTestDraftStream({ previewMode: "draft" });
|
||||||
answerStream.previewRevision.mockImplementation(() => previewRevision);
|
answerStream.previewRevision.mockImplementation(() => previewRevision);
|
||||||
answerStream.lastDeliveredText.mockReturnValue("Older partial");
|
|
||||||
answerStream.update.mockImplementation(() => {});
|
answerStream.update.mockImplementation(() => {});
|
||||||
answerStream.flush.mockImplementation(async () => {
|
answerStream.flush.mockImplementation(async () => {
|
||||||
previewRevision += 1;
|
previewRevision += 1;
|
||||||
@@ -322,9 +260,6 @@ describe("createLaneTextDeliverer", () => {
|
|||||||
expect.objectContaining({ text: "Final answer" }),
|
expect.objectContaining({ text: "Final answer" }),
|
||||||
);
|
);
|
||||||
expect(harness.markDelivered).not.toHaveBeenCalled();
|
expect(harness.markDelivered).not.toHaveBeenCalled();
|
||||||
expect(harness.log).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("draft final text not emitted"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not use DM draft final shortcut for media payloads", async () => {
|
it("does not use DM draft final shortcut for media payloads", async () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type DraftLaneState = {
|
|||||||
stream: TelegramDraftStream | undefined;
|
stream: TelegramDraftStream | undefined;
|
||||||
lastPartialText: string;
|
lastPartialText: string;
|
||||||
hasStreamedMessage: boolean;
|
hasStreamedMessage: boolean;
|
||||||
previewRevisionBaseline: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArchivedPreview = {
|
export type ArchivedPreview = {
|
||||||
@@ -329,43 +328,6 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
|||||||
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
|
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
|
||||||
|
|
||||||
if (infoKind === "final") {
|
if (infoKind === "final") {
|
||||||
const hasPreviewButtons = Boolean(previewButtons?.some((row) => row.length > 0));
|
|
||||||
const canFinalizeDraftPreviewDirectly =
|
|
||||||
isDraftPreviewLane(lane) &&
|
|
||||||
lane.hasStreamedMessage &&
|
|
||||||
canEditViaPreview &&
|
|
||||||
!hasPreviewButtons;
|
|
||||||
let draftPreviewStopped = false;
|
|
||||||
if (canFinalizeDraftPreviewDirectly) {
|
|
||||||
const previewRevisionBeforeFlush = lane.stream?.previewRevision?.() ?? 0;
|
|
||||||
const finalTextSnapshot = text.trimEnd();
|
|
||||||
const hasEmittedPreviewInCurrentLane =
|
|
||||||
previewRevisionBeforeFlush > lane.previewRevisionBaseline;
|
|
||||||
const deliveredPreviewTextBeforeFinal = lane.stream?.lastDeliveredText?.() ?? "";
|
|
||||||
const finalTextAlreadyDelivered =
|
|
||||||
deliveredPreviewTextBeforeFinal === finalTextSnapshot && hasEmittedPreviewInCurrentLane;
|
|
||||||
const unchangedFinalText = text === lane.lastPartialText;
|
|
||||||
lane.stream?.update(text);
|
|
||||||
await params.flushDraftLane(lane);
|
|
||||||
await params.stopDraftLane(lane);
|
|
||||||
draftPreviewStopped = true;
|
|
||||||
const previewUpdated = (lane.stream?.previewRevision?.() ?? 0) > previewRevisionBeforeFlush;
|
|
||||||
const deliveredPreviewTextAfterFinal =
|
|
||||||
lane.stream?.lastDeliveredText?.() ?? deliveredPreviewTextBeforeFinal;
|
|
||||||
if (
|
|
||||||
(previewUpdated && deliveredPreviewTextAfterFinal === finalTextSnapshot) ||
|
|
||||||
(unchangedFinalText && finalTextAlreadyDelivered)
|
|
||||||
) {
|
|
||||||
lane.lastPartialText = text;
|
|
||||||
params.finalizedPreviewByLane[laneName] = true;
|
|
||||||
params.markDelivered();
|
|
||||||
return "preview-finalized";
|
|
||||||
}
|
|
||||||
params.log(
|
|
||||||
`telegram: ${laneName} draft final text not emitted; falling back to standard send`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (laneName === "answer") {
|
if (laneName === "answer") {
|
||||||
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
|
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
|
||||||
lane,
|
lane,
|
||||||
@@ -378,7 +340,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
|||||||
return archivedResult;
|
return archivedResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canEditViaPreview && !params.finalizedPreviewByLane[laneName] && !draftPreviewStopped) {
|
if (canEditViaPreview && !params.finalizedPreviewByLane[laneName]) {
|
||||||
await params.flushDraftLane(lane);
|
await params.flushDraftLane(lane);
|
||||||
if (laneName === "answer") {
|
if (laneName === "answer") {
|
||||||
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
|
const archivedResultAfterFlush = await consumeArchivedAnswerPreviewForFinal({
|
||||||
@@ -410,9 +372,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
|||||||
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
|
`telegram: preview final too long for edit (${text.length} > ${params.draftMaxChars}); falling back to standard send`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!draftPreviewStopped) {
|
await params.stopDraftLane(lane);
|
||||||
await params.stopDraftLane(lane);
|
|
||||||
}
|
|
||||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||||
return delivered ? "sent" : "skipped";
|
return delivered ? "sent" : "skipped";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user