





























@@ -480,6 +480,16 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
480480unbind: 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+}));
483493vi.mock("../../infra/agent-events.js", () => ({
484494emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
485495onAgentEvent: (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;
597612let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
598613let dispatchFromConfigTesting: typeof import("./dispatch-from-config.js").testing;
599614let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
@@ -854,6 +869,28 @@ function firstRouteReplyCall(): Record<string, unknown> {
854869return call as Record<string, unknown>;
855870}
856871872+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+857894function requireToolResultHandler(
858895handler: 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+});
9481004setActivePluginRegistry(
9491005createTestRegistry([
9501006{
@@ -957,6 +1013,7 @@ describe("dispatchReplyFromConfig", () => {
9571013source: "test",
9581014plugin: signalTestPlugin,
9591015},
1016+ ...passiveThreadingTestPlugins,
9601017]),
9611018);
9621019clearApprovalNativeRouteStateForTest();
@@ -1209,7 +1266,8 @@ describe("dispatchReplyFromConfig", () => {
12091266it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
12101267setNoAbort();
12111268mocks.routeReply.mockClear();
1212-const cfg = emptyConfig;
1269+installThreadingTestPlugin({ id: "slack", defaultAccountId: "work" });
1270+const cfg = automaticDirectReplyConfig;
12131271const dispatcher = createDispatcher();
12141272const ctx = buildTestCtx({
12151273Provider: "slack",
@@ -1227,11 +1285,23 @@ describe("dispatchReplyFromConfig", () => {
1227128512281286expect(mocks.routeReply).not.toHaveBeenCalled();
12291287expect(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});
1231130012321301it("mirrors ownerless same-channel Slack finals after successful delivery", async () => {
12331302setNoAbort();
12341303mocks.routeReply.mockClear();
1304+installThreadingTestPlugin({ id: "slack" });
12351305const dispatcher = createDispatcher();
12361306const ctx = buildTestCtx({
12371307Provider: "slack",
@@ -1273,6 +1343,7 @@ describe("dispatchReplyFromConfig", () => {
1273134312741344it("mirrors reset acknowledgements into the canonically prepared Slack session", async () => {
12751345setNoAbort();
1346+hookMocks.runner.hasHooks.mockReturnValue(false);
12761347const dispatcher = createDispatcher();
12771348const sessionKey = "Agent:Main:Slack:Channel:C123";
12781349const preparedSessionKey = "agent:main:slack:channel:c123";
@@ -1352,16 +1423,19 @@ describe("dispatchReplyFromConfig", () => {
13521423setNoAbort();
13531424const dispatcher = createDispatcher();
13541425mocks.routeReply.mockClear();
1426+hookMocks.runner.hasHooks.mockReturnValue(false);
1427+installThreadingTestPlugin({ id: "telegram", defaultAccountId: "default" });
1355142813561429const result = await dispatchReplyFromConfig({
13571430ctx: buildTestCtx({
13581431Provider: "slack",
13591432Surface: "slack",
13601433OriginatingChannel: "telegram",
13611434OriginatingTo: "telegram:999",
1435+AccountId: "default",
13621436SessionKey: "agent:main:telegram:group:999",
13631437}),
1364-cfg: emptyConfig,
1438+cfg: automaticDirectReplyConfig,
13651439 dispatcher,
13661440replyResolver: async () =>
13671441setReplyPayloadMetadata(
@@ -1581,6 +1655,7 @@ describe("dispatchReplyFromConfig", () => {
1581165515821656it("keeps non-Slack routed direct turns behind the active reply operation", async () => {
15831657setNoAbort();
1658+installThreadingTestPlugin({ id: "telegram" });
15841659const sessionKey = "agent:main:telegram:direct:1";
15851660const activeOperation = createReplyOperation({
15861661 sessionKey,
@@ -1627,6 +1702,7 @@ describe("dispatchReplyFromConfig", () => {
16271702it("routes when OriginatingChannel differs from Provider", async () => {
16281703setNoAbort();
16291704mocks.routeReply.mockClear();
1705+installThreadingTestPlugin({ id: "telegram" });
16301706const cfg = emptyConfig;
16311707const dispatcher = createDispatcher();
16321708const ctx = buildTestCtx({
@@ -1667,6 +1743,7 @@ describe("dispatchReplyFromConfig", () => {
16671743it("routes exec-event replies using persisted session delivery context when current turn has no originating route", async () => {
16681744setNoAbort();
16691745mocks.routeReply.mockClear();
1746+installThreadingTestPlugin({ id: "telegram" });
16701747sessionStoreMocks.currentEntry = {
16711748deliveryContext: {
16721749channel: "telegram",
@@ -1724,6 +1801,7 @@ describe("dispatchReplyFromConfig", () => {
17241801it("routes sessions_send internal webchat handoffs through persisted external delivery context", async () => {
17251802setNoAbort();
17261803mocks.routeReply.mockClear();
1804+installThreadingTestPlugin({ id: "feishu" });
17271805sessionStoreMocks.currentEntry = {
17281806route: {
17291807channel: "feishu",
@@ -1835,6 +1913,7 @@ describe("dispatchReplyFromConfig", () => {
18351913it("honors sendPolicy deny for recovered exec-event delivery channel", async () => {
18361914setNoAbort();
18371915mocks.routeReply.mockClear();
1916+installThreadingTestPlugin({ id: "telegram" });
18381917sessionStoreMocks.currentEntry = {
18391918deliveryContext: {
18401919channel: "telegram",
@@ -1908,6 +1987,7 @@ describe("dispatchReplyFromConfig", () => {
19081987it("uses Slack DM TransportThreadId when ReplyToId is the current message", async () => {
19091988setNoAbort();
19101989mocks.routeReply.mockClear();
1990+installThreadingTestPlugin({ id: "slack" });
19111991const cfg = emptyConfig;
19121992const dispatcher = createDispatcher();
19131993const ctx = buildTestCtx({
@@ -1935,6 +2015,7 @@ describe("dispatchReplyFromConfig", () => {
19352015it("does not resurrect a cleared route thread from origin metadata", async () => {
19362016setNoAbort();
19372017mocks.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.
19402021sessionStoreMocks.currentEntry = {
@@ -1975,6 +2056,7 @@ describe("dispatchReplyFromConfig", () => {
1975205619762057it("forces suppressTyping when routing to a different originating channel", async () => {
19772058setNoAbort();
2059+installThreadingTestPlugin({ id: "telegram" });
19782060const cfg = emptyConfig;
19792061const dispatcher = createDispatcher();
19802062const ctx = buildTestCtx({
@@ -2015,6 +2097,7 @@ describe("dispatchReplyFromConfig", () => {
20152097it("routes when provider is webchat but surface carries originating channel metadata", async () => {
20162098setNoAbort();
20172099mocks.routeReply.mockClear();
2100+installThreadingTestPlugin({ id: "telegram" });
20182101const cfg = emptyConfig;
20192102const dispatcher = createDispatcher();
20202103const ctx = buildTestCtx({
@@ -2036,6 +2119,7 @@ describe("dispatchReplyFromConfig", () => {
20362119it("routes Feishu replies when provider is webchat and origin metadata points to Feishu", async () => {
20372120setNoAbort();
20382121mocks.routeReply.mockClear();
2122+installThreadingTestPlugin({ id: "feishu" });
20392123const cfg = emptyConfig;
20402124const dispatcher = createDispatcher();
20412125const ctx = buildTestCtx({
@@ -2076,6 +2160,7 @@ describe("dispatchReplyFromConfig", () => {
20762160it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => {
20772161setNoAbort();
20782162mocks.routeReply.mockClear();
2163+installThreadingTestPlugin({ id: "imessage" });
20792164const cfg = emptyConfig;
20802165const dispatcher = createDispatcher();
20812166const ctx = buildTestCtx({
@@ -2099,6 +2184,7 @@ describe("dispatchReplyFromConfig", () => {
20992184it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => {
21002185setNoAbort();
21012186mocks.routeReply.mockClear();
2187+installThreadingTestPlugin({ id: "imessage" });
21022188const cfg = emptyConfig;
21032189const dispatcher = createDispatcher();
21042190const ctx = buildTestCtx({
@@ -2128,6 +2214,7 @@ describe("dispatchReplyFromConfig", () => {
21282214it("routes media-only tool results when summaries are suppressed", async () => {
21292215setNoAbort();
21302216mocks.routeReply.mockClear();
2217+installThreadingTestPlugin({ id: "telegram" });
21312218const cfg = automaticGroupReplyConfig;
21322219const dispatcher = createDispatcher();
21332220const ctx = buildTestCtx({
@@ -5497,6 +5584,7 @@ describe("dispatchReplyFromConfig", () => {
5497558454985585it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
54995586setNoAbort();
5587+hookMocks.runner.hasHooks.mockReturnValue(false);
55005588const cfg = emptyConfig;
55015589const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
55025590const baseCtx = buildTestCtx({
@@ -5530,6 +5618,7 @@ describe("dispatchReplyFromConfig", () => {
55305618it("emits message_received hook with originating channel metadata", async () => {
55315619setNoAbort();
55325620hookMocks.runner.hasHooks.mockReturnValue(true);
5621+installThreadingTestPlugin({ id: "telegram" });
55335622const cfg = emptyConfig;
55345623const dispatcher = createDispatcher();
55355624const ctx = buildTestCtx({
@@ -5789,6 +5878,7 @@ describe("dispatchReplyFromConfig", () => {
57895878// would receive divergent keys on every native redirect.
57905879setNoAbort();
57915880mocks.routeReply.mockClear();
5881+installThreadingTestPlugin({ id: "telegram" });
57925882const cfg = emptyConfig;
57935883const dispatcher = createDispatcher();
57945884const ctx = buildTestCtx({
@@ -5825,6 +5915,7 @@ describe("dispatchReplyFromConfig", () => {
58255915// generalization of the native-redirect branch.
58265916setNoAbort();
58275917mocks.routeReply.mockClear();
5918+installThreadingTestPlugin({ id: "telegram" });
58285919const cfg = emptyConfig;
58295920const dispatcher = createDispatcher();
58305921const ctx = buildTestCtx({
@@ -7669,6 +7760,7 @@ describe("before_dispatch hook", () => {
76697760it("uses canonical hook metadata and shared routed final delivery", async () => {
76707761ttsMocks.state.synthesizeFinalAudio = true;
76717762hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
7763+installThreadingTestPlugin({ id: "telegram" });
76727764const dispatcher = createDispatcher();
76737765const ctx = createHookCtx({
76747766Body: "raw body",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。