fix: flush block streaming on paragraph boundaries for chunkMode=newline (#7014)

* feat: Implement paragraph boundary flushing in block streaming

- Added `flushOnParagraph` option to `BlockReplyChunking` for immediate flushing on paragraph breaks.
- Updated `EmbeddedBlockChunker` to handle paragraph boundaries during chunking.
- Enhanced `createBlockReplyCoalescer` to support flushing on enqueue.
- Added tests to verify behavior of flushing with and without `flushOnEnqueue` set.
- Updated relevant types and interfaces to include `flushOnParagraph` and `flushOnEnqueue` options.

* fix: Improve streaming behavior and enhance block chunking logic

- Resolved issue with stuck typing indicator after streamed BlueBubbles replies.
- Refactored `EmbeddedBlockChunker` to streamline fence-split handling and ensure maxChars fallback for newline chunking.
- Added tests to validate new chunking behavior, including handling of paragraph breaks and fence scenarios.
- Updated changelog to reflect these changes.

* test: Add test for clamping long paragraphs in EmbeddedBlockChunker

- Introduced a new test case to verify that long paragraphs are correctly clamped to maxChars when flushOnParagraph is enabled.
- Updated logic in EmbeddedBlockChunker to handle cases where the next paragraph break exceeds maxChars, ensuring proper chunking behavior.

* refactor: streamline logging and improve error handling in message processing

- Removed verbose logging statements from the `processMessage` function to reduce clutter.
- Enhanced error handling by using `runtime.error` for typing restart failures.
- Updated the `applySystemPromptOverrideToSession` function to accept a string directly instead of a function, simplifying the prompt application process.
- Adjusted the `runEmbeddedAttempt` function to directly use the system prompt override without invoking it as a function.
This commit is contained in:
Tyler Yust
2026-02-02 01:22:41 -08:00
committed by GitHub
parent 85cd55e22b
commit 9ef24fd400
14 changed files with 377 additions and 73 deletions

View File

@@ -2145,12 +2145,40 @@ async function processMessage(
};
let sentMessage = false;
let streamingActive = false;
let typingRestartTimer: NodeJS.Timeout | undefined;
const typingRestartDelayMs = 150;
const clearTypingRestartTimer = () => {
if (typingRestartTimer) {
clearTimeout(typingRestartTimer);
typingRestartTimer = undefined;
}
};
const restartTypingSoon = () => {
if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
return;
}
clearTypingRestartTimer();
typingRestartTimer = setTimeout(() => {
typingRestartTimer = undefined;
if (!streamingActive) {
return;
}
sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
})
.catch((err) => {
runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
});
}, typingRestartDelayMs);
};
try {
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
deliver: async (payload, info) => {
const rawReplyToId =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
@@ -2185,6 +2213,9 @@ async function processMessage(
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
if (info.kind === "block") {
restartTypingSoon();
}
}
return;
}
@@ -2220,16 +2251,8 @@ async function processMessage(
maybeEnqueueOutboundMessageId(result.messageId, chunk);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
// In newline mode, restart typing after each chunk if more chunks remain
// Small delay allows the Apple API to finish clearing the typing state from message send
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
await new Promise((r) => setTimeout(r, 150));
sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
}).catch(() => {
// Ignore typing errors
});
if (info.kind === "block") {
restartTypingSoon();
}
}
},
@@ -2240,7 +2263,8 @@ async function processMessage(
if (!baseUrl || !password) {
return;
}
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
streamingActive = true;
clearTypingRestartTimer();
try {
await sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
@@ -2257,14 +2281,8 @@ async function processMessage(
if (!baseUrl || !password) {
return;
}
try {
await sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
}
// Intentionally no-op for block streaming. We stop typing in finally
// after the run completes to avoid flicker between paragraph blocks.
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -2278,6 +2296,10 @@ async function processMessage(
},
});
} finally {
const shouldStopTyping =
Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
streamingActive = false;
clearTypingRestartTimer();
if (sentMessage && chatGuidForActions && ackMessageId) {
core.channel.reactions.removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
@@ -2301,8 +2323,8 @@ async function processMessage(
},
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
if (shouldStopTyping) {
// Stop typing after streaming completes to avoid a stuck indicator.
sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,