fix: preserve message route and account defaults

This commit is contained in:
joshavant
2026-07-26 22:37:13 -05:00
committed by Josh Avant
parent 568cf47812
commit bc1a317e29
6 changed files with 838 additions and 569 deletions
@@ -57,6 +57,26 @@ describe("resolveFeishuToolAccount", () => {
expect(resolved.accountId).toBe("work");
});
it("allows the explicit unlisted default backed by top-level credentials", () => {
const resolved = resolveFeishuToolAccount({
api: {
config: {
channels: {
feishu: {
defaultAccount: "ops",
appId: "base-app-id",
appSecret: "base-app-secret", // pragma: allowlist secret
},
},
},
},
executeParams: { accountId: "OPS" },
});
expect(resolved.accountId).toBe("ops");
expect(resolved.configured).toBe(true);
});
it.each([
{ name: "malformed", accountId: "!!!", error: "Invalid Feishu account ID" },
{ name: "unknown", accountId: "missing", error: "Unknown Feishu account" },
+11 -3
View File
@@ -5,6 +5,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import type { OpenClawPluginApi } from "../runtime-api.js";
import {
listFeishuAccountIds,
resolveDefaultFeishuAccountId,
resolveFeishuAccount,
resolveFeishuRuntimeAccount,
} from "./accounts.js";
@@ -31,9 +32,16 @@ function resolveImplicitToolAccountId(params: {
if (!normalizedAccountId) {
throw new Error(`Invalid Feishu account ID "${explicitAccountId}"`);
}
const listedAccountId = listFeishuAccountIds(params.api.config).find(
(accountId) => normalizeOptionalAccountId(accountId) === normalizedAccountId,
);
const listedAccountId =
listFeishuAccountIds(params.api.config).find(
(accountId) => normalizeOptionalAccountId(accountId) === normalizedAccountId,
) ??
(() => {
const defaultAccountId = resolveDefaultFeishuAccountId(params.api.config);
return normalizeOptionalAccountId(defaultAccountId) === normalizedAccountId
? defaultAccountId
: undefined;
})();
if (!listedAccountId) {
throw new Error(`Unknown Feishu account "${explicitAccountId}"`);
}
+82 -2
View File
@@ -1034,8 +1034,9 @@ describe("gateway send mirroring", () => {
isWebchatConnect: () => false,
});
await Promise.resolve();
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2);
await vi.waitFor(() => {
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2);
});
expect(mocks.dispatchChannelMessageAction.mock.calls[0]?.[0]).toMatchObject({
conversationReadOrigin: "direct-operator",
});
@@ -1441,6 +1442,85 @@ describe("gateway send mirroring", () => {
expect(firstRespondCall(retryRespond)?.[3]?.cached).toBe(true);
});
it.each([
{
name: "send",
method: "send" as const,
request: {
to: "channel:C1",
message: "hi",
idempotencyKey: "idem-send-deferred-route-race",
},
providerCall: mocks.deliverOutboundPayloads,
},
{
name: "poll",
method: "poll" as const,
request: {
to: "channel:C1",
question: "Q?",
options: ["A", "B"],
idempotencyKey: "idem-poll-deferred-route-race",
},
providerCall: mocks.sendPoll,
},
])(
"keeps the first deferred $name route when a concurrent retry sees newer defaults",
async (testCase) => {
const firstSelection = createDeferred<{ channel: string; configured: string[] }>();
mocks.resolveMessageChannelSelection
.mockImplementationOnce(async () => await firstSelection.promise)
.mockResolvedValue({ channel: "discord", configured: ["discord"] });
mockMutableMessageRouteAccounts(() => "primary");
const providerDeferred = createDeferred<unknown>();
if (testCase.method === "send") {
mocks.deliverOutboundPayloads.mockReturnValueOnce(providerDeferred.promise as never);
} else {
mocks.sendPoll.mockReturnValueOnce(providerDeferred.promise as never);
}
const context = makeContext();
const firstRespond = vi.fn();
const retryRespond = vi.fn();
const firstRequest = invokeGatewayMessageMethod({
method: testCase.method,
request: testCase.request,
respond: firstRespond,
context,
});
await vi.waitFor(() => {
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
});
const retryRequest = invokeGatewayMessageMethod({
method: testCase.method,
request: testCase.request,
respond: retryRespond,
context,
});
await Promise.resolve();
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
firstSelection.resolve({ channel: "slack", configured: ["slack"] });
await vi.waitFor(() => {
expect(testCase.providerCall).toHaveBeenCalledTimes(1);
});
if (testCase.method === "send") {
providerDeferred.resolve([{ messageId: "m-race", channel: "slack" }]);
} else {
providerDeferred.resolve({ messageId: "poll-race", pollId: "poll-race" });
}
await Promise.all([firstRequest, retryRequest]);
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledTimes(1);
expect(testCase.providerCall).toHaveBeenCalledTimes(1);
expect(firstRespondCall(firstRespond)?.[0]).toBe(true);
expect(firstRespondCall(retryRespond)?.[0]).toBe(true);
expect(firstRespondCall(retryRespond)?.[3]?.cached).toBe(true);
},
);
it("dedupes omitted and explicit default poll routes", async () => {
const context = makeContext();
const firstRespond = vi.fn();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { validateExplicitMessageAccountSelection } from "./message-account-selection.js";
describe("validateExplicitMessageAccountSelection", () => {
const cfg = {} as OpenClawConfig;
const plugin = {
id: "feishu",
config: {
listAccountIds: () => ["default"],
defaultAccountId: () => "ops",
resolveAccount: (_cfg: OpenClawConfig, accountId?: string | null) => ({
accountId,
enabled: true,
}),
},
} as unknown as ChannelPlugin;
it("accepts the plugin-resolved default when it is intentionally unlisted", () => {
expect(
validateExplicitMessageAccountSelection({
cfg,
channel: "feishu",
accountId: "OPS",
plugin,
}),
).toBe("ops");
});
it("still rejects a non-default unlisted account", () => {
expect(() =>
validateExplicitMessageAccountSelection({
cfg,
channel: "feishu",
accountId: "missing",
plugin,
}),
).toThrow('Unknown account "missing"');
});
});
@@ -1,5 +1,6 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveChannelAccountEnabled } from "../../channels/account-summary.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
@@ -21,9 +22,19 @@ function resolveListedAccountId(params: {
cfg: OpenClawConfig;
accountId: string;
}): string | undefined {
return params.plugin.config
const listedAccountId = params.plugin.config
.listAccountIds(params.cfg)
.find((candidate) => normalizeOptionalAccountId(candidate) === params.accountId);
if (listedAccountId) {
return listedAccountId;
}
const defaultAccountId = resolveChannelDefaultAccountId({
plugin: params.plugin,
cfg: params.cfg,
});
return normalizeOptionalAccountId(defaultAccountId) === params.accountId
? defaultAccountId
: undefined;
}
function isExplicitAccountDisabled(params: {