test(msteams): share thread handler test setup

This commit is contained in:
Vincent Koc
2026-04-12 03:21:21 +01:00
parent 7e6b4d70b9
commit d00ab06048
3 changed files with 142 additions and 237 deletions

View File

@@ -0,0 +1,103 @@
import { vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
import { setMSTeamsRuntime } from "../runtime.js";
export const channelConversationId = "19:general@thread.tacv2";
export function createMessageHandlerDeps(cfg: OpenClawConfig) {
const enqueueSystemEvent = vi.fn();
const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined);
const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({
sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`,
agentId: "main",
accountId: "default",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session" as const,
matchedBy: "default" as const,
}));
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => null),
},
text: {
hasControlCommand: () => false,
resolveTextChunkLimit: () => 4000,
},
routing: { resolveAgentRoute },
reply: {
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,
},
session: {
recordInboundSession,
resolveStorePath: () => "/tmp/test-store",
},
},
} as unknown as PluginRuntime);
const deps: MSTeamsMessageHandlerDeps = {
cfg,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app",
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
tokenProvider: {
getAccessToken: vi.fn(async () => "token"),
},
textLimit: 4000,
mediaMaxBytes: 1024 * 1024,
conversationStore: {
get: vi.fn(async () => null),
upsert: vi.fn(async () => undefined),
list: vi.fn(async () => []),
remove: vi.fn(async () => false),
findPreferredDmByUserId: vi.fn(async () => null),
findByUserId: vi.fn(async () => null),
} satisfies MSTeamsMessageHandlerDeps["conversationStore"],
pollStore: {
recordVote: vi.fn(async () => null),
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
log: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as MSTeamsMessageHandlerDeps["log"],
};
return {
deps,
enqueueSystemEvent,
recordInboundSession,
resolveAgentRoute,
};
}
export function buildChannelActivity(overrides: Record<string, unknown> = {}) {
return {
id: "msg-1",
type: "message",
text: "hello",
from: { id: "user-id", aadObjectId: "user-aad", name: "Test User" },
recipient: { id: "bot-id", name: "Bot" },
conversation: { id: channelConversationId, conversationType: "channel" },
channelData: { team: { id: "team-1" } },
attachments: [],
entities: [{ type: "mention", mentioned: { id: "bot-id" } }],
...overrides,
};
}

View File

@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
import { setMSTeamsRuntime } from "../runtime.js";
import type { OpenClawConfig } from "../../runtime-api.js";
import { _resetThreadParentContextCachesForTest } from "../thread-parent-context.js";
import { createMSTeamsMessageHandler } from "./message-handler.js";
import {
buildChannelActivity,
channelConversationId,
createMessageHandlerDeps,
} from "./message-handler.test-support.js";
const runtimeApiMockState = vi.hoisted(() => ({
dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({
@@ -46,100 +49,6 @@ vi.mock("../reply-dispatcher.js", () => ({
}));
describe("msteams thread parent context injection", () => {
const channelConversationId = "19:general@thread.tacv2";
function createDeps(cfg: OpenClawConfig) {
const enqueueSystemEvent = vi.fn();
const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined);
const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({
sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`,
agentId: "main",
accountId: "default",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session" as const,
matchedBy: "default" as const,
}));
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => null),
},
text: {
hasControlCommand: () => false,
resolveTextChunkLimit: () => 4000,
},
routing: { resolveAgentRoute },
reply: {
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,
},
session: {
recordInboundSession,
resolveStorePath: () => "/tmp/test-store",
},
},
} as unknown as PluginRuntime);
const deps: MSTeamsMessageHandlerDeps = {
cfg,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app",
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
tokenProvider: {
getAccessToken: vi.fn(async () => "token"),
},
textLimit: 4000,
mediaMaxBytes: 1024 * 1024,
conversationStore: {
get: vi.fn(async () => null),
upsert: vi.fn(async () => undefined),
list: vi.fn(async () => []),
remove: vi.fn(async () => false),
findPreferredDmByUserId: vi.fn(async () => null),
findByUserId: vi.fn(async () => null),
} satisfies MSTeamsMessageHandlerDeps["conversationStore"],
pollStore: {
recordVote: vi.fn(async () => null),
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
log: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as MSTeamsMessageHandlerDeps["log"],
};
return { deps, enqueueSystemEvent };
}
function channelActivity(overrides: Record<string, unknown> = {}) {
return {
id: "msg-1",
type: "message",
text: "hello",
from: { id: "user-id", aadObjectId: "user-aad", name: "Test User" },
recipient: { id: "bot-id", name: "Bot" },
conversation: { id: channelConversationId, conversationType: "channel" },
channelData: { team: { id: "team-1" } },
attachments: [],
entities: [{ type: "mention", mentioned: { id: "bot-id" } }],
...overrides,
};
}
function findParentSystemEventCall(
mock: ReturnType<typeof vi.fn>,
): [string, { sessionKey: string; contextKey?: string }] | undefined {
@@ -167,11 +76,11 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Alice", id: "alice-id" } },
body: { content: "Can someone investigate the latency spike?", contentType: "text" },
});
const { deps, enqueueSystemEvent } = createDeps(cfg);
const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -188,16 +97,16 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Alice" } },
body: { content: "Original question", contentType: "text" },
});
const { deps } = createDeps(cfg);
const { deps } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
await handler({
activity: channelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -211,16 +120,16 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Alice" } },
body: { content: "Original question", contentType: "text" },
});
const { deps, enqueueSystemEvent } = createDeps(cfg);
const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
await handler({
activity: channelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -236,7 +145,7 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Mallory", id: "mallory-aad" } },
body: { content: "Blocked context", contentType: "text" },
});
const { deps, enqueueSystemEvent } = createDeps({
const { deps, enqueueSystemEvent } = createMessageHandlerDeps({
channels: {
msteams: {
groupPolicy: "allowlist",
@@ -255,7 +164,7 @@ describe("msteams thread parent context injection", () => {
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({
activity: buildChannelActivity({
id: "msg-reply-1",
replyToId: "thread-root-123",
from: { id: "alice-id", aadObjectId: "alice-aad", name: "Alice" },
@@ -268,11 +177,11 @@ describe("msteams thread parent context injection", () => {
it("handles Graph failure gracefully without throwing or emitting a parent event", async () => {
fetchChannelMessageMock.mockRejectedValueOnce(new Error("graph down"));
const { deps, enqueueSystemEvent } = createDeps(cfg);
const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -288,14 +197,14 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Alice" } },
body: { content: "should-not-happen", contentType: "text" },
});
const { deps, enqueueSystemEvent } = createDeps({
const { deps, enqueueSystemEvent } = createMessageHandlerDeps({
channels: { msteams: { allowFrom: ["*"] } },
} as OpenClawConfig);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: {
...channelActivity(),
...buildChannelActivity(),
conversation: { id: "a:dm-conversation", conversationType: "personal" },
channelData: {},
replyToId: "dm-parent",
@@ -314,11 +223,11 @@ describe("msteams thread parent context injection", () => {
from: { user: { displayName: "Alice" } },
body: { content: "should-not-happen", contentType: "text" },
});
const { deps, enqueueSystemEvent } = createDeps(cfg);
const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: channelActivity({ id: "msg-root-1", replyToId: undefined }),
activity: buildChannelActivity({ id: "msg-root-1", replyToId: undefined }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);

View File

@@ -1,9 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
import type { MSTeamsConversationStore } from "../conversation-store.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
import { setMSTeamsRuntime } from "../runtime.js";
import type { OpenClawConfig } from "../../runtime-api.js";
import { createMSTeamsMessageHandler } from "./message-handler.js";
import {
buildChannelActivity,
channelConversationId,
createMessageHandlerDeps,
} from "./message-handler.test-support.js";
const runtimeApiMockState = vi.hoisted(() => ({
dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({
@@ -42,125 +44,16 @@ vi.mock("../reply-dispatcher.js", () => ({
}));
describe("msteams thread session isolation", () => {
const channelConversationId = "19:general@thread.tacv2";
function createDeps(cfg: OpenClawConfig) {
const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined);
const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({
sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`,
agentId: "main",
accountId: "default",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session" as const,
matchedBy: "default" as const,
}));
setMSTeamsRuntime({
logging: { shouldLogVerbose: () => false },
system: { enqueueSystemEvent: vi.fn() },
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
enqueue: async (entry: T) => {
await params.onFlush([entry]);
},
}),
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => null),
},
text: {
hasControlCommand: () => false,
resolveTextChunkLimit: () => 4000,
},
routing: {
resolveAgentRoute,
},
reply: {
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,
},
session: {
recordInboundSession,
resolveStorePath: () => "/tmp/test-store",
},
},
} as unknown as PluginRuntime);
const deps: MSTeamsMessageHandlerDeps = {
cfg,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app",
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
tokenProvider: {
getAccessToken: vi.fn(async () => "token"),
},
textLimit: 4000,
mediaMaxBytes: 1024 * 1024,
conversationStore: {
get: vi.fn(async () => null),
upsert: vi.fn(async () => undefined),
list: vi.fn(async () => []),
remove: vi.fn(async () => false),
findPreferredDmByUserId: vi.fn(async () => null),
findByUserId: vi.fn(async () => null),
} satisfies MSTeamsConversationStore,
pollStore: {
recordVote: vi.fn(async () => null),
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
log: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
} as unknown as MSTeamsMessageHandlerDeps["log"],
};
return {
deps,
recordInboundSession,
resolveAgentRoute,
};
}
function buildActivity(overrides: Record<string, unknown> = {}) {
return {
id: "msg-1",
type: "message",
text: "hello",
from: {
id: "user-id",
aadObjectId: "user-aad",
name: "Test User",
},
recipient: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: channelConversationId,
conversationType: "channel",
},
channelData: { team: { id: "team-1" } },
attachments: [],
entities: [{ type: "mention", mentioned: { id: "bot-id" } }],
...overrides,
};
}
it("appends thread suffix to session key for channel thread replies", async () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
// Thread reply: has replyToId pointing to the thread root
await handler({
activity: buildActivity({ replyToId: "thread-root-123" }),
activity: buildChannelActivity({ replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -174,12 +67,12 @@ describe("msteams thread session isolation", () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
// Top-level channel message: no replyToId
await handler({
activity: buildActivity({ replyToId: undefined }),
activity: buildChannelActivity({ replyToId: undefined }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -193,16 +86,16 @@ describe("msteams thread session isolation", () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: buildActivity({ id: "msg-1", replyToId: "thread-A" }),
activity: buildChannelActivity({ id: "msg-1", replyToId: "thread-A" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
await handler({
activity: buildActivity({ id: "msg-2", replyToId: "thread-B" }),
activity: buildChannelActivity({ id: "msg-2", replyToId: "thread-B" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
@@ -218,12 +111,12 @@ describe("msteams thread session isolation", () => {
const cfg: OpenClawConfig = {
channels: { msteams: { allowFrom: ["*"] } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: {
...buildActivity(),
...buildChannelActivity(),
conversation: {
id: "a:dm-conversation",
conversationType: "personal",
@@ -244,12 +137,12 @@ describe("msteams thread session isolation", () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const { deps, recordInboundSession } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: {
...buildActivity(),
...buildChannelActivity(),
conversation: {
id: "19:group-chat-id@unq.gbl.spaces",
conversationType: "groupChat",