Skip to content

Commit f90ec6d

Browse files
committed
fix(tests): avoid runtime discovery in routed reply checks
1 parent 1a002c2 commit f90ec6d

4 files changed

Lines changed: 123 additions & 11 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,16 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
480480
unbind: vi.fn(async () => []),
481481
}),
482482
}));
483+
vi.mock("../../bindings/records.js", () => ({
484+
resolveConversationBindingRecord: (conversation: {
485+
channel: string;
486+
accountId: string;
487+
conversationId: string;
488+
parentConversationId?: string;
489+
}) => sessionBindingMocks.resolveByConversation(conversation),
490+
touchConversationBindingRecord: (...args: [bindingId: string, at?: number]) =>
491+
sessionBindingMocks.touch(...args),
492+
}));
483493
vi.mock("../../infra/agent-events.js", () => ({
484494
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
485495
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
@@ -594,6 +604,11 @@ const automaticGroupReplyConfig = {
594604
},
595605
},
596606
} as const satisfies OpenClawConfig;
607+
const automaticDirectReplyConfig = {
608+
messages: {
609+
visibleReplies: "automatic",
610+
},
611+
} as const satisfies OpenClawConfig;
597612
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
598613
let dispatchFromConfigTesting: typeof import("./dispatch-from-config.js").testing;
599614
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
@@ -854,6 +869,28 @@ function firstRouteReplyCall(): Record<string, unknown> {
854869
return call as Record<string, unknown>;
855870
}
856871

872+
function installThreadingTestPlugin(params: { defaultAccountId?: string; id: string }) {
873+
const plugin = createChannelTestPluginBase({ id: params.id });
874+
const defaultAccountId = params.defaultAccountId;
875+
setActivePluginRegistry(
876+
createTestRegistry([
877+
{
878+
pluginId: params.id,
879+
source: "test",
880+
plugin: {
881+
...plugin,
882+
config: defaultAccountId
883+
? { ...plugin.config, defaultAccountId: () => defaultAccountId }
884+
: plugin.config,
885+
threading: {
886+
resolveReplyToMode: () => "all",
887+
},
888+
},
889+
},
890+
]),
891+
);
892+
}
893+
857894
function requireToolResultHandler(
858895
handler: GetReplyOptions["onToolResult"] | undefined,
859896
): NonNullable<GetReplyOptions["onToolResult"]> {
@@ -945,6 +982,25 @@ describe("dispatchReplyFromConfig", () => {
945982
),
946983
},
947984
};
985+
const passiveThreadingTestPlugins = [
986+
"slack",
987+
"telegram",
988+
"feishu",
989+
"mattermost",
990+
"imessage",
991+
].map((id) => {
992+
const plugin = createChannelTestPluginBase({ id });
993+
return {
994+
pluginId: id,
995+
source: "test" as const,
996+
plugin: {
997+
...plugin,
998+
threading: {
999+
resolveReplyToMode: () => "all" as const,
1000+
},
1001+
},
1002+
};
1003+
});
9481004
setActivePluginRegistry(
9491005
createTestRegistry([
9501006
{
@@ -957,6 +1013,7 @@ describe("dispatchReplyFromConfig", () => {
9571013
source: "test",
9581014
plugin: signalTestPlugin,
9591015
},
1016+
...passiveThreadingTestPlugins,
9601017
]),
9611018
);
9621019
clearApprovalNativeRouteStateForTest();
@@ -1209,7 +1266,8 @@ describe("dispatchReplyFromConfig", () => {
12091266
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
12101267
setNoAbort();
12111268
mocks.routeReply.mockClear();
1212-
const cfg = emptyConfig;
1269+
installThreadingTestPlugin({ id: "slack", defaultAccountId: "work" });
1270+
const cfg = automaticDirectReplyConfig;
12131271
const dispatcher = createDispatcher();
12141272
const ctx = buildTestCtx({
12151273
Provider: "slack",
@@ -1227,11 +1285,23 @@ describe("dispatchReplyFromConfig", () => {
12271285

12281286
expect(mocks.routeReply).not.toHaveBeenCalled();
12291287
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
1288+
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
1289+
| [
1290+
{
1291+
originatingAccountId?: unknown;
1292+
shouldRouteToOriginating?: unknown;
1293+
},
1294+
unknown,
1295+
]
1296+
| undefined;
1297+
expect(replyDispatchCall?.[0]?.shouldRouteToOriginating).toBe(false);
1298+
expect(replyDispatchCall?.[0]?.originatingAccountId).toBe("work");
12301299
});
12311300

12321301
it("mirrors ownerless same-channel Slack finals after successful delivery", async () => {
12331302
setNoAbort();
12341303
mocks.routeReply.mockClear();
1304+
installThreadingTestPlugin({ id: "slack" });
12351305
const dispatcher = createDispatcher();
12361306
const ctx = buildTestCtx({
12371307
Provider: "slack",
@@ -1273,6 +1343,7 @@ describe("dispatchReplyFromConfig", () => {
12731343

12741344
it("mirrors reset acknowledgements into the canonically prepared Slack session", async () => {
12751345
setNoAbort();
1346+
hookMocks.runner.hasHooks.mockReturnValue(false);
12761347
const dispatcher = createDispatcher();
12771348
const sessionKey = "Agent:Main:Slack:Channel:C123";
12781349
const preparedSessionKey = "agent:main:slack:channel:c123";
@@ -1352,16 +1423,19 @@ describe("dispatchReplyFromConfig", () => {
13521423
setNoAbort();
13531424
const dispatcher = createDispatcher();
13541425
mocks.routeReply.mockClear();
1426+
hookMocks.runner.hasHooks.mockReturnValue(false);
1427+
installThreadingTestPlugin({ id: "telegram", defaultAccountId: "default" });
13551428

13561429
const result = await dispatchReplyFromConfig({
13571430
ctx: buildTestCtx({
13581431
Provider: "slack",
13591432
Surface: "slack",
13601433
OriginatingChannel: "telegram",
13611434
OriginatingTo: "telegram:999",
1435+
AccountId: "default",
13621436
SessionKey: "agent:main:telegram:group:999",
13631437
}),
1364-
cfg: emptyConfig,
1438+
cfg: automaticDirectReplyConfig,
13651439
dispatcher,
13661440
replyResolver: async () =>
13671441
setReplyPayloadMetadata(
@@ -1581,6 +1655,7 @@ describe("dispatchReplyFromConfig", () => {
15811655

15821656
it("keeps non-Slack routed direct turns behind the active reply operation", async () => {
15831657
setNoAbort();
1658+
installThreadingTestPlugin({ id: "telegram" });
15841659
const sessionKey = "agent:main:telegram:direct:1";
15851660
const activeOperation = createReplyOperation({
15861661
sessionKey,
@@ -1627,6 +1702,7 @@ describe("dispatchReplyFromConfig", () => {
16271702
it("routes when OriginatingChannel differs from Provider", async () => {
16281703
setNoAbort();
16291704
mocks.routeReply.mockClear();
1705+
installThreadingTestPlugin({ id: "telegram" });
16301706
const cfg = emptyConfig;
16311707
const dispatcher = createDispatcher();
16321708
const ctx = buildTestCtx({
@@ -1667,6 +1743,7 @@ describe("dispatchReplyFromConfig", () => {
16671743
it("routes exec-event replies using persisted session delivery context when current turn has no originating route", async () => {
16681744
setNoAbort();
16691745
mocks.routeReply.mockClear();
1746+
installThreadingTestPlugin({ id: "telegram" });
16701747
sessionStoreMocks.currentEntry = {
16711748
deliveryContext: {
16721749
channel: "telegram",
@@ -1724,6 +1801,7 @@ describe("dispatchReplyFromConfig", () => {
17241801
it("routes sessions_send internal webchat handoffs through persisted external delivery context", async () => {
17251802
setNoAbort();
17261803
mocks.routeReply.mockClear();
1804+
installThreadingTestPlugin({ id: "feishu" });
17271805
sessionStoreMocks.currentEntry = {
17281806
route: {
17291807
channel: "feishu",
@@ -1835,6 +1913,7 @@ describe("dispatchReplyFromConfig", () => {
18351913
it("honors sendPolicy deny for recovered exec-event delivery channel", async () => {
18361914
setNoAbort();
18371915
mocks.routeReply.mockClear();
1916+
installThreadingTestPlugin({ id: "telegram" });
18381917
sessionStoreMocks.currentEntry = {
18391918
deliveryContext: {
18401919
channel: "telegram",
@@ -1908,6 +1987,7 @@ describe("dispatchReplyFromConfig", () => {
19081987
it("uses Slack DM TransportThreadId when ReplyToId is the current message", async () => {
19091988
setNoAbort();
19101989
mocks.routeReply.mockClear();
1990+
installThreadingTestPlugin({ id: "slack" });
19111991
const cfg = emptyConfig;
19121992
const dispatcher = createDispatcher();
19131993
const ctx = buildTestCtx({
@@ -1935,6 +2015,7 @@ describe("dispatchReplyFromConfig", () => {
19352015
it("does not resurrect a cleared route thread from origin metadata", async () => {
19362016
setNoAbort();
19372017
mocks.routeReply.mockClear();
2018+
installThreadingTestPlugin({ id: "mattermost" });
19382019
// Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from
19392020
// origin.threadId on read, but a non-thread session key must still route to channel root.
19402021
sessionStoreMocks.currentEntry = {
@@ -1975,6 +2056,7 @@ describe("dispatchReplyFromConfig", () => {
19752056

19762057
it("forces suppressTyping when routing to a different originating channel", async () => {
19772058
setNoAbort();
2059+
installThreadingTestPlugin({ id: "telegram" });
19782060
const cfg = emptyConfig;
19792061
const dispatcher = createDispatcher();
19802062
const ctx = buildTestCtx({
@@ -2015,6 +2097,7 @@ describe("dispatchReplyFromConfig", () => {
20152097
it("routes when provider is webchat but surface carries originating channel metadata", async () => {
20162098
setNoAbort();
20172099
mocks.routeReply.mockClear();
2100+
installThreadingTestPlugin({ id: "telegram" });
20182101
const cfg = emptyConfig;
20192102
const dispatcher = createDispatcher();
20202103
const ctx = buildTestCtx({
@@ -2036,6 +2119,7 @@ describe("dispatchReplyFromConfig", () => {
20362119
it("routes Feishu replies when provider is webchat and origin metadata points to Feishu", async () => {
20372120
setNoAbort();
20382121
mocks.routeReply.mockClear();
2122+
installThreadingTestPlugin({ id: "feishu" });
20392123
const cfg = emptyConfig;
20402124
const dispatcher = createDispatcher();
20412125
const ctx = buildTestCtx({
@@ -2076,6 +2160,7 @@ describe("dispatchReplyFromConfig", () => {
20762160
it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => {
20772161
setNoAbort();
20782162
mocks.routeReply.mockClear();
2163+
installThreadingTestPlugin({ id: "imessage" });
20792164
const cfg = emptyConfig;
20802165
const dispatcher = createDispatcher();
20812166
const ctx = buildTestCtx({
@@ -2099,6 +2184,7 @@ describe("dispatchReplyFromConfig", () => {
20992184
it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => {
21002185
setNoAbort();
21012186
mocks.routeReply.mockClear();
2187+
installThreadingTestPlugin({ id: "imessage" });
21022188
const cfg = emptyConfig;
21032189
const dispatcher = createDispatcher();
21042190
const ctx = buildTestCtx({
@@ -2128,6 +2214,7 @@ describe("dispatchReplyFromConfig", () => {
21282214
it("routes media-only tool results when summaries are suppressed", async () => {
21292215
setNoAbort();
21302216
mocks.routeReply.mockClear();
2217+
installThreadingTestPlugin({ id: "telegram" });
21312218
const cfg = automaticGroupReplyConfig;
21322219
const dispatcher = createDispatcher();
21332220
const ctx = buildTestCtx({
@@ -5497,6 +5584,7 @@ describe("dispatchReplyFromConfig", () => {
54975584

54985585
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
54995586
setNoAbort();
5587+
hookMocks.runner.hasHooks.mockReturnValue(false);
55005588
const cfg = emptyConfig;
55015589
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
55025590
const baseCtx = buildTestCtx({
@@ -5530,6 +5618,7 @@ describe("dispatchReplyFromConfig", () => {
55305618
it("emits message_received hook with originating channel metadata", async () => {
55315619
setNoAbort();
55325620
hookMocks.runner.hasHooks.mockReturnValue(true);
5621+
installThreadingTestPlugin({ id: "telegram" });
55335622
const cfg = emptyConfig;
55345623
const dispatcher = createDispatcher();
55355624
const ctx = buildTestCtx({
@@ -5789,6 +5878,7 @@ describe("dispatchReplyFromConfig", () => {
57895878
// would receive divergent keys on every native redirect.
57905879
setNoAbort();
57915880
mocks.routeReply.mockClear();
5881+
installThreadingTestPlugin({ id: "telegram" });
57925882
const cfg = emptyConfig;
57935883
const dispatcher = createDispatcher();
57945884
const ctx = buildTestCtx({
@@ -5825,6 +5915,7 @@ describe("dispatchReplyFromConfig", () => {
58255915
// generalization of the native-redirect branch.
58265916
setNoAbort();
58275917
mocks.routeReply.mockClear();
5918+
installThreadingTestPlugin({ id: "telegram" });
58285919
const cfg = emptyConfig;
58295920
const dispatcher = createDispatcher();
58305921
const ctx = buildTestCtx({
@@ -7669,6 +7760,7 @@ describe("before_dispatch hook", () => {
76697760
it("uses canonical hook metadata and shared routed final delivery", async () => {
76707761
ttsMocks.state.synthesizeFinalAudio = true;
76717762
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
7763+
installThreadingTestPlugin({ id: "telegram" });
76727764
const dispatcher = createDispatcher();
76737765
const ctx = createHookCtx({
76747766
Body: "raw body",

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,10 +1606,12 @@ export async function dispatchReplyFromConfig(
16061606
});
16071607
const routeReplyTo = replyRoute.to;
16081608
const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface;
1609-
const routedReplyAccountId = routeReplyChannel
1609+
const shouldPrepareRoutedReplyDelivery = shouldRouteToOriginating && Boolean(routeReplyChannel);
1610+
const replyContextAccountId = routeReplyChannel
16101611
? resolveReplyDeliveryAccountId(cfg, routeReplyChannel, replyRoute.accountId)
16111612
: undefined;
1612-
const routedReplyDelivery = routeReplyChannel
1613+
const routedReplyAccountId = shouldPrepareRoutedReplyDelivery ? replyContextAccountId : undefined;
1614+
const routedReplyDelivery = shouldPrepareRoutedReplyDelivery
16131615
? createReplyDeliveryContext(
16141616
resolveReplyToMode(cfg, routeReplyChannel, routedReplyAccountId, replyRoute.chatType),
16151617
replyRoute.chatType,
@@ -1630,7 +1632,7 @@ export async function dispatchReplyFromConfig(
16301632
sessionKey: acpDispatchSessionKey,
16311633
workspaceDir,
16321634
messageProvider: deliveryChannel,
1633-
accountId: routedReplyAccountId,
1635+
accountId: replyContextAccountId,
16341636
groupId,
16351637
groupChannel: ctx.GroupChannel,
16361638
groupSpace: ctx.GroupSpace,
@@ -2500,7 +2502,7 @@ export async function dispatchReplyFromConfig(
25002502
shouldRouteToOriginating,
25012503
originatingChannel: routeReplyChannel,
25022504
originatingTo: routeReplyTo,
2503-
originatingAccountId: routedReplyAccountId,
2505+
originatingAccountId: replyContextAccountId,
25042506
originatingThreadId: routeReplyThreadId,
25052507
originatingChatType: replyRoute.chatType,
25062508
shouldSendToolSummaries,
@@ -3216,7 +3218,7 @@ export async function dispatchReplyFromConfig(
32163218
shouldRouteToOriginating,
32173219
originatingChannel: routeReplyChannel,
32183220
originatingTo: routeReplyTo,
3219-
originatingAccountId: routedReplyAccountId,
3221+
originatingAccountId: replyContextAccountId,
32203222
originatingThreadId: routeReplyThreadId,
32213223
originatingChatType: replyRoute.chatType,
32223224
shouldSendToolSummaries,

src/commands/agent.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import {
2525
import type { PluginProviderRegistration } from "../plugins/registry.js";
2626
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
2727
import type { RuntimeEnv } from "../runtime.js";
28-
import { createTestRegistry } from "../test-utils/channel-plugins.js";
28+
import {
29+
createDirectOutboundTestAdapter,
30+
createOutboundTestPlugin,
31+
createTestRegistry,
32+
} from "../test-utils/channel-plugins.js";
2933
import { agentCommand, agentCommandFromIngress, testing as agentCommandTesting } from "./agent.js";
3034
import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js";
3135

@@ -63,6 +67,10 @@ vi.mock("../agents/auth-profiles/store.js", () => {
6367
};
6468
});
6569

70+
vi.mock("../agents/auth-profiles/source-check.js", () => ({
71+
hasAnyAuthProfileStoreSource: vi.fn(() => false),
72+
}));
73+
6674
vi.mock("../agents/command/session-store.runtime.js", () => {
6775
return {
6876
updateSessionStoreAfterAgentRun: vi.fn(async () => undefined),
@@ -336,8 +344,8 @@ function mockModelCatalogOnce(entries: ReturnType<typeof loadManifestModelCatalo
336344
vi.mocked(loadModelCatalog).mockResolvedValueOnce(entries);
337345
}
338346

339-
function installThinkingTestProviders() {
340-
const registry = createTestRegistry();
347+
function installThinkingTestProviders(channels: Parameters<typeof createTestRegistry>[0] = []) {
348+
const registry = createTestRegistry(channels);
341349
registry.providers = ["anthropic", "codex", "ollama", "openai", "openrouter"].map(
342350
(providerId): PluginProviderRegistration => ({
343351
pluginId: providerId,
@@ -1311,6 +1319,16 @@ describe("agentCommand", () => {
13111319
},
13121320
});
13131321
mockConfig(home, store);
1322+
installThinkingTestProviders([
1323+
{
1324+
pluginId: "telegram",
1325+
source: "test",
1326+
plugin: createOutboundTestPlugin({
1327+
id: "telegram",
1328+
outbound: createDirectOutboundTestAdapter({ channel: "telegram" }),
1329+
}),
1330+
},
1331+
]);
13141332

13151333
await agentCommand(
13161334
{ message: "hi", to: sessionKey, deliver: true, channel: "telegram" },

0 commit comments

Comments
 (0)