fix(routing): handle cross-provider messages in collect mode

When queued messages come from different providers (Slack + Telegram),
process them individually instead of collecting into a single prompt.
This ensures each reply routes back to its originating provider.

- Add hasCrossProviderItems() to detect multi-provider queues
- Skip collect mode when cross-provider detected
- Preserve originatingChannel/originatingTo when collecting same-provider
This commit is contained in:
Josh Lehman
2026-01-06 13:43:10 -08:00
committed by Peter Steinberger
parent 2d67ec5bfa
commit 5414da9fd4
2 changed files with 59 additions and 3 deletions

View File

@@ -82,9 +82,13 @@ export function createFollowupRunner(params: {
cfg: queued.run.config,
});
if (!result.ok) {
logVerbose(
`followup queue: route-reply failed: ${result.error ?? "unknown error"}`,
);
// Log error and fall back to dispatcher if available.
const errorMsg = result.error ?? "unknown error";
logVerbose(`followup queue: route-reply failed: ${errorMsg}`);
// Fallback: try the dispatcher if routing failed.
if (opts?.onBlockReply) {
await opts.onBlockReply(payload);
}
}
} else if (opts?.onBlockReply) {
await opts.onBlockReply(payload);

View File

@@ -5,6 +5,7 @@ import type { SessionEntry } from "../../config/sessions.js";
import { defaultRuntime } from "../../runtime.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./directives.js";
import { isRoutableChannel } from "./route-reply.js";
export type QueueMode =
| "steer"
| "followup"
@@ -386,6 +387,33 @@ function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
});
return blocks.join("\n\n");
}
/**
* Checks if queued items have different routable originating channels.
*
* Returns true if messages come from different providers (e.g., Slack + Telegram),
* meaning they cannot be safely collected into one prompt without losing routing.
* Also returns true for a mix of routable and non-routable channels.
*/
function hasCrossProviderItems(items: FollowupRun[]): boolean {
const routableChannels = new Set<string>();
let hasNonRoutable = false;
for (const item of items) {
const channel = item.originatingChannel;
if (isRoutableChannel(channel)) {
routableChannels.add(channel);
} else if (channel) {
// Has a channel but it's not routable (whatsapp, webchat).
hasNonRoutable = true;
}
}
// Cross-provider if: multiple routable channels, or mix of routable + non-routable.
return (
routableChannels.size > 1 || (routableChannels.size > 0 && hasNonRoutable)
);
}
export function scheduleFollowupDrain(
key: string,
runFollowup: (run: FollowupRun) => Promise<void>,
@@ -398,15 +426,39 @@ export function scheduleFollowupDrain(
while (queue.items.length > 0 || queue.droppedCount > 0) {
await waitForQueueDebounce(queue);
if (queue.mode === "collect") {
// Check if messages span multiple providers.
// If so, process individually to preserve per-message routing.
const isCrossProvider = hasCrossProviderItems(queue.items);
if (isCrossProvider) {
// Process one at a time to preserve per-message routing info.
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
continue;
}
// Same-provider messages can be safely collected.
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break;
// Preserve originating channel from items when collecting same-provider.
const originatingChannel = items.find(
(i) => i.originatingChannel,
)?.originatingChannel;
const originatingTo = items.find(
(i) => i.originatingTo,
)?.originatingTo;
const prompt = buildCollectPrompt(items, summary);
await runFollowup({
prompt,
run,
enqueuedAt: Date.now(),
originatingChannel,
originatingTo,
});
continue;
}