Compare commits

...

1 Commits

Author SHA1 Message Date
Tyler Yust
793ad7d557 Fix BlueBubbles command output chunking 2026-03-25 23:49:11 -07:00
2 changed files with 196 additions and 2 deletions

View File

@@ -89,6 +89,26 @@ function trimOrUndefined(value?: string | null): string | undefined {
return trimmed ? trimmed : undefined;
}
function shouldPreferLogicalSectionChunking(
ctx: {
BodyForCommands?: string;
CommandBody?: string;
RawBody?: string;
Body?: string;
CommandAuthorized?: boolean;
},
kind: "tool" | "block" | "final",
): boolean {
if (kind === "tool") {
return true;
}
if (ctx.CommandAuthorized !== true) {
return false;
}
const body = (ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "").trim();
return body.startsWith("/") || body.startsWith("!");
}
function normalizeSnippet(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
@@ -1339,8 +1359,14 @@ export async function processMessage(
const text = sanitizeReplyDirectiveText(
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
);
const chunks =
chunkMode === "newline"
const useLogicalSectionChunking =
chunkMode === "newline" && shouldPreferLogicalSectionChunking(ctxPayload, info.kind);
const chunks = useLogicalSectionChunking
? resolveTextChunksWithFallback(
text,
core.channel.text.chunkMarkdownTextWithMode(text, textLimit, "newline"),
)
: chunkMode === "newline"
? resolveTextChunksWithFallback(
text,
core.channel.text.chunkTextWithMode(text, textLimit, chunkMode),

View File

@@ -1145,6 +1145,174 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("logical section chunking", () => {
it("keeps slash command replies grouped by blank-line sections", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const sendMock = vi.mocked(sendMessageBlueBubbles);
sendMock.mockClear();
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
mockChunkTextWithMode.mockImplementation((text: string) =>
text
.split("\n")
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockChunkMarkdownTextWithMode.mockImplementation((text: string) =>
text
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
params.ctx.CommandAuthorized = true;
params.ctx.CommandBody = "/subagents";
params.ctx.BodyForCommands = "/subagents";
await params.dispatcherOptions.deliver(
{
text: [
"active subagents:",
"1. alpha",
"2. beta",
"",
"recent subagents:",
"1. gamma",
"2. delta",
].join("\n"),
},
{ kind: "final" },
);
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget({
account: createMockAccount({ chunkMode: "newline" }),
});
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "/subagents",
chatGuid: "iMessage;-;+15551234567",
}),
);
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls.map((call) => call[1])).toEqual([
"active subagents:\n1. alpha\n2. beta",
"recent subagents:\n1. gamma\n2. delta",
]);
});
it("leaves normal assistant replies on the configured newline chunker", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const sendMock = vi.mocked(sendMessageBlueBubbles);
sendMock.mockClear();
mockChunkTextWithMode.mockImplementation((text: string) =>
text
.split("\n")
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockChunkMarkdownTextWithMode.mockImplementation((text: string) =>
text
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver(
{
text: [
"active subagents:",
"1. alpha",
"2. beta",
"",
"recent subagents:",
"1. gamma",
"2. delta",
].join("\n"),
},
{ kind: "final" },
);
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget({
account: createMockAccount({ chunkMode: "newline" }),
});
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "hello there",
chatGuid: "iMessage;-;+15551234567",
}),
);
expect(sendMock.mock.calls.map((call) => call[1])).toEqual([
"active subagents:",
"1. alpha",
"2. beta",
"recent subagents:",
"1. gamma",
"2. delta",
]);
});
it("keeps tool summaries grouped by blank-line sections", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const sendMock = vi.mocked(sendMessageBlueBubbles);
sendMock.mockClear();
mockChunkTextWithMode.mockImplementation((text: string) =>
text
.split("\n")
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockChunkMarkdownTextWithMode.mockImplementation((text: string) =>
text
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.filter(Boolean),
);
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver(
{
text: [
"active subagents:",
"1. alpha",
"2. beta",
"",
"recent subagents:",
"1. gamma",
"2. delta",
].join("\n"),
},
{ kind: "tool" },
);
return EMPTY_DISPATCH_RESULT;
});
setupWebhookTarget({
account: createMockAccount({ chunkMode: "newline" }),
});
await dispatchWebhookPayload(
createTimestampedNewMessagePayloadForTest({
text: "status pls",
chatGuid: "iMessage;-;+15551234567",
}),
);
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock.mock.calls.map((call) => call[1])).toEqual([
"active subagents:\n1. alpha\n2. beta",
"recent subagents:\n1. gamma\n2. delta",
]);
});
});
describe("reaction events", () => {
it("drops DM reactions when dmPolicy=pairing and allowFrom is empty", async () => {
mockEnqueueSystemEvent.mockClear();