fix: stop hardcoded channel fallback and auto-pick sole configured channel (#23357) (thanks @lbo728)

Co-authored-by: lbo728 <extreme0728@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-22 11:20:33 +01:00
parent e33d7fcd13
commit 1cd3b30907
18 changed files with 355 additions and 91 deletions

View File

@@ -15,6 +15,7 @@ import {
resolveAgentDeliveryPlan,
resolveAgentOutboundTarget,
} from "../../infra/outbound/agent-delivery.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { classifySessionKeyShape, normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
@@ -490,17 +491,36 @@ export const agentHandlers: GatewayRequestHandlers = {
wantsDelivery,
});
const resolvedChannel = deliveryPlan.resolvedChannel;
const deliveryTargetMode = deliveryPlan.deliveryTargetMode;
const resolvedAccountId = deliveryPlan.resolvedAccountId;
let resolvedChannel = deliveryPlan.resolvedChannel;
let deliveryTargetMode = deliveryPlan.deliveryTargetMode;
let resolvedAccountId = deliveryPlan.resolvedAccountId;
let resolvedTo = deliveryPlan.resolvedTo;
let effectivePlan = deliveryPlan;
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
const cfgResolved = cfgForAgent ?? cfg;
try {
const selection = await resolveMessageChannelSelection({ cfg: cfgResolved });
resolvedChannel = selection.channel;
deliveryTargetMode = deliveryTargetMode ?? "implicit";
effectivePlan = {
...deliveryPlan,
resolvedChannel,
deliveryTargetMode,
resolvedAccountId,
};
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfgResolved = cfgForAgent ?? cfg;
const fallback = resolveAgentOutboundTarget({
cfg: cfgResolved,
plan: deliveryPlan,
targetMode: "implicit",
plan: effectivePlan,
targetMode: deliveryTargetMode ?? "implicit",
validateExplicitTarget: false,
});
if (fallback.resolvedTarget?.ok) {
@@ -508,6 +528,18 @@ export const agentHandlers: GatewayRequestHandlers = {
}
}
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"delivery channel is required: pass --channel/--reply-channel or use a main session with a previous channel",
),
);
return;
}
const deliver = request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL;
const accepted = {

View File

@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })),
resolveMessageChannelSelection: vi.fn(),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
}));
vi.mock("../../config/config.js", async () => {
@@ -20,7 +22,7 @@ vi.mock("../../config/config.js", async () => {
});
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: () => ({ outbound: {} }),
getChannelPlugin: () => ({ outbound: { sendPoll: mocks.sendPoll } }),
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
}));
@@ -28,6 +30,10 @@ vi.mock("../../infra/outbound/targets.js", () => ({
resolveOutboundTarget: mocks.resolveOutboundTarget,
}));
vi.mock("../../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
vi.mock("../../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
@@ -61,6 +67,19 @@ async function runSend(params: Record<string, unknown>) {
return { respond };
}
async function runPoll(params: Record<string, unknown>) {
const respond = vi.fn();
await sendHandlers.poll({
params: params as never,
respond,
context: makeContext(),
req: { type: "req", id: "1", method: "poll" },
client: null,
isWebchatConnect: () => false,
});
return { respond };
}
function mockDeliverySuccess(messageId: string) {
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]);
}
@@ -69,6 +88,11 @@ describe("gateway send mirroring", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" });
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "slack",
configured: ["slack"],
});
mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" });
});
it("accepts media-only sends without message", async () => {
@@ -137,6 +161,81 @@ describe("gateway send mirroring", () => {
);
});
it("auto-picks the single configured channel for send", async () => {
mockDeliverySuccess("m-single-send");
const { respond } = await runSend({
to: "x",
message: "hi",
idempotencyKey: "idem-missing-channel",
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
expect(mocks.deliverOutboundPayloads).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ messageId: "m-single-send" }),
undefined,
expect.objectContaining({ channel: "slack" }),
);
});
it("returns invalid request when send channel selection is ambiguous", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
const { respond } = await runSend({
to: "x",
message: "hi",
idempotencyKey: "idem-missing-channel-ambiguous",
});
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("Channel is required"),
}),
);
});
it("auto-picks the single configured channel for poll", async () => {
const { respond } = await runPoll({
to: "x",
question: "Q?",
options: ["A", "B"],
idempotencyKey: "idem-poll-missing-channel",
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined, {
channel: "slack",
});
});
it("returns invalid request when poll channel selection is ambiguous", async () => {
mocks.resolveMessageChannelSelection.mockRejectedValueOnce(
new Error("Channel is required when multiple channels are configured: telegram, slack"),
);
const { respond } = await runPoll({
to: "x",
question: "Q?",
options: ["A", "B"],
idempotencyKey: "idem-poll-missing-channel-ambiguous",
});
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("Channel is required"),
}),
);
});
it("does not mirror when delivery returns no results", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);

View File

@@ -1,8 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { createOutboundSendDeps } from "../../cli/deps.js";
import { loadConfig } from "../../config/config.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import {
ensureOutboundSessionEntry,
@@ -126,7 +126,16 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
@@ -148,7 +157,6 @@ export const sendHandlers: GatewayRequestHandlers = {
const work = (async (): Promise<InflightResult> => {
try {
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
channel: outboundChannel,
to,
@@ -324,7 +332,16 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const channel = normalizedChannel ?? DEFAULT_CHAT_CHANNEL;
const cfg = loadConfig();
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
if (typeof request.durationSeconds === "number" && channel !== "telegram") {
respond(
false,
@@ -370,7 +387,6 @@ export const sendHandlers: GatewayRequestHandlers = {
);
return;
}
const cfg = loadConfig();
const resolved = resolveOutboundTarget({
channel: channel,
to,