test: share msteams monitor and pi runner fixtures

This commit is contained in:
Peter Steinberger
2026-03-26 15:39:57 +00:00
parent 339cc33cf8
commit c4048aea41
6 changed files with 235 additions and 373 deletions

View File

@@ -3,13 +3,15 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import {
type MSTeamsActivityHandler,
type MSTeamsMessageHandlerDeps,
registerMSTeamsHandlers,
} from "./monitor-handler.js";
import {
createActivityHandler,
createMSTeamsMessageHandlerDeps,
} from "./monitor-handler.test-helpers.js";
import type { MSTeamsPollStore } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
@@ -57,66 +59,16 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
} as unknown as PluginRuntime;
}
function createActivityHandler(run = vi.fn(async () => undefined)): MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
} {
let handler: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
handler = {
onMessage: () => handler,
onMembersAdded: () => handler,
onReactionsAdded: () => handler,
onReactionsRemoved: () => handler,
run,
};
return handler;
}
function createDeps(params: {
cfg: OpenClawConfig;
readAllowFromStore?: ReturnType<typeof vi.fn>;
}): MSTeamsMessageHandlerDeps {
const readAllowFromStore = params.readAllowFromStore ?? vi.fn(async () => []);
setMSTeamsRuntime(createRuntimeStub(readAllowFromStore));
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
updateActivity: async () => {},
deleteActivity: async () => {},
};
const conversationStore: MSTeamsConversationStore = {
upsert: async () => {},
get: async () => null,
list: async () => [],
remove: async () => false,
findByUserId: async () => null,
};
const pollStore: MSTeamsPollStore = {
createPoll: async () => {},
getPoll: async () => null,
recordVote: async () => null,
};
return {
return createMSTeamsMessageHandlerDeps({
cfg: params.cfg,
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
appId: "test-app-id",
adapter,
tokenProvider: {
getAccessToken: async () => "token",
},
textLimit: 4000,
mediaMaxBytes: 8 * 1024 * 1024,
conversationStore,
pollStore,
log: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
});
}
function createFeedbackInvokeContext(params: {
@@ -174,6 +126,33 @@ async function expectFileMissing(filePath: string) {
await expect(access(filePath)).rejects.toThrow();
}
async function withFeedbackHandler(params: {
cfg: OpenClawConfig;
context: Parameters<typeof createFeedbackInvokeContext>[0];
assertResult: (args: { tmpDir: string; originalRun: ReturnType<typeof vi.fn> }) => Promise<void>;
}) {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const originalRun = vi.fn(async () => undefined);
const handler = registerMSTeamsHandlers(
createActivityHandler(originalRun),
createDeps({
cfg: {
...params.cfg,
session: { store: tmpDir },
},
}),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(createFeedbackInvokeContext(params.context));
await params.assertResult({ tmpDir, originalRun });
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
describe("msteams feedback invoke authz", () => {
beforeEach(() => {
feedbackReflectionMockState.runFeedbackReflection.mockReset();
@@ -181,148 +160,106 @@ describe("msteams feedback invoke authz", () => {
});
it("records feedback for an allowlisted DM sender", async () => {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const originalRun = vi.fn(async () => undefined);
const handler = registerMSTeamsHandlers(
createActivityHandler(originalRun),
createDeps({
cfg: {
session: { store: tmpDir },
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
},
},
} as OpenClawConfig,
}),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(
createFeedbackInvokeContext({
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "owner-aad",
senderName: "Owner",
comment: "allowed feedback",
}),
);
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
expect(JSON.parse(transcript.trim())).toMatchObject({
event: "feedback",
messageId: "bot-msg-1",
value: "positive",
await withFeedbackHandler({
cfg: {
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
},
},
} as OpenClawConfig,
context: {
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "owner-aad",
senderName: "Owner",
comment: "allowed feedback",
sessionKey: "msteams:direct:owner-aad",
conversationId: "a:personal-chat",
});
expect(originalRun).not.toHaveBeenCalled();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
},
assertResult: async ({ tmpDir, originalRun }) => {
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
expect(JSON.parse(transcript.trim())).toMatchObject({
event: "feedback",
messageId: "bot-msg-1",
value: "positive",
comment: "allowed feedback",
sessionKey: "msteams:direct:owner-aad",
conversationId: "a:personal-chat",
});
expect(originalRun).not.toHaveBeenCalled();
},
});
});
it("keeps DM feedback allowed when team route allowlists exist", async () => {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const originalRun = vi.fn(async () => undefined);
const handler = registerMSTeamsHandlers(
createActivityHandler(originalRun),
createDeps({
cfg: {
session: { store: tmpDir },
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
teams: {
team123: {
channels: {
"19:group@thread.tacv2": { requireMention: false },
},
},
await withFeedbackHandler({
cfg: {
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
teams: {
team123: {
channels: {
"19:group@thread.tacv2": { requireMention: false },
},
},
},
} as OpenClawConfig,
}),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(
createFeedbackInvokeContext({
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "owner-aad",
senderName: "Owner",
comment: "allowed dm feedback",
}),
);
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
expect(JSON.parse(transcript.trim())).toMatchObject({
event: "feedback",
value: "positive",
},
},
} as OpenClawConfig,
context: {
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "owner-aad",
senderName: "Owner",
comment: "allowed dm feedback",
sessionKey: "msteams:direct:owner-aad",
});
expect(originalRun).not.toHaveBeenCalled();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
},
assertResult: async ({ tmpDir, originalRun }) => {
const transcript = await readFile(
path.join(tmpDir, "msteams_direct_owner-aad.jsonl"),
"utf-8",
);
expect(JSON.parse(transcript.trim())).toMatchObject({
event: "feedback",
value: "positive",
comment: "allowed dm feedback",
sessionKey: "msteams:direct:owner-aad",
});
expect(originalRun).not.toHaveBeenCalled();
},
});
});
it("does not record feedback for a DM sender outside allowFrom", async () => {
const tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-msteams-feedback-"));
try {
const originalRun = vi.fn(async () => undefined);
const handler = registerMSTeamsHandlers(
createActivityHandler(originalRun),
createDeps({
cfg: {
session: { store: tmpDir },
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
},
},
} as OpenClawConfig,
}),
) as MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
await handler.run(
createFeedbackInvokeContext({
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "attacker-aad",
senderName: "Attacker",
comment: "blocked feedback",
}),
);
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
expect(originalRun).not.toHaveBeenCalled();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
await withFeedbackHandler({
cfg: {
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: ["owner-aad"],
},
},
} as OpenClawConfig,
context: {
reaction: "like",
conversationId: "a:personal-chat;messageid=bot-msg-1",
conversationType: "personal",
senderId: "attacker-aad",
senderName: "Attacker",
comment: "blocked feedback",
},
assertResult: async ({ tmpDir, originalRun }) => {
await expectFileMissing(path.join(tmpDir, "msteams_direct_attacker-aad.jsonl"));
expect(feedbackReflectionMockState.runFeedbackReflection).not.toHaveBeenCalled();
expect(originalRun).not.toHaveBeenCalled();
},
});
});
it("does not trigger reflection for a group sender outside groupAllowFrom", async () => {

View File

@@ -1,14 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import {
type MSTeamsActivityHandler,
type MSTeamsMessageHandlerDeps,
registerMSTeamsHandlers,
} from "./monitor-handler.js";
import {
createActivityHandler,
createMSTeamsMessageHandlerDeps,
} from "./monitor-handler.test-helpers.js";
import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
@@ -39,56 +40,12 @@ const runtimeStub: PluginRuntime = {
} as unknown as PluginRuntime;
function createDeps(): MSTeamsMessageHandlerDeps {
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
updateActivity: async () => {},
deleteActivity: async () => {},
};
const conversationStore: MSTeamsConversationStore = {
upsert: async () => {},
get: async () => null,
list: async () => [],
remove: async () => false,
findByUserId: async () => null,
};
const pollStore: MSTeamsPollStore = {
createPoll: async () => {},
getPoll: async () => null,
recordVote: async () => null,
};
return {
return createMSTeamsMessageHandlerDeps({
cfg: {} as OpenClawConfig,
runtime: {
error: vi.fn(),
} as unknown as RuntimeEnv,
appId: "test-app-id",
adapter,
tokenProvider: {
getAccessToken: async () => "token",
},
textLimit: 4000,
mediaMaxBytes: 8 * 1024 * 1024,
conversationStore,
pollStore,
log: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
}
function createActivityHandler(): MSTeamsActivityHandler {
let handler: MSTeamsActivityHandler;
handler = {
onMessage: () => handler,
onMembersAdded: () => handler,
onReactionsAdded: () => handler,
onReactionsRemoved: () => handler,
run: async () => {},
};
return handler;
});
}
function createInvokeContext(params: {

View File

@@ -0,0 +1,67 @@
import { vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsActivityHandler, MSTeamsMessageHandlerDeps } from "./monitor-handler.js";
import type { MSTeamsPollStore } from "./polls.js";
export function createActivityHandler(
run = vi.fn(async () => undefined),
): MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
} {
let handler: MSTeamsActivityHandler & {
run: NonNullable<MSTeamsActivityHandler["run"]>;
};
handler = {
onMessage: () => handler,
onMembersAdded: () => handler,
onReactionsAdded: () => handler,
onReactionsRemoved: () => handler,
run,
};
return handler;
}
export function createMSTeamsMessageHandlerDeps(params?: {
cfg?: OpenClawConfig;
runtime?: RuntimeEnv;
}): MSTeamsMessageHandlerDeps {
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
updateActivity: async () => {},
deleteActivity: async () => {},
};
const conversationStore: MSTeamsConversationStore = {
upsert: async () => {},
get: async () => null,
list: async () => [],
remove: async () => false,
findByUserId: async () => null,
};
const pollStore: MSTeamsPollStore = {
createPoll: async () => {},
getPoll: async () => null,
recordVote: async () => null,
};
return {
cfg: (params?.cfg ?? {}) as OpenClawConfig,
runtime: (params?.runtime ?? { error: vi.fn() }) as RuntimeEnv,
appId: "test-app-id",
adapter,
tokenProvider: {
getAccessToken: async () => "token",
},
textLimit: 4000,
mediaMaxBytes: 8 * 1024 * 1024,
conversationStore,
pollStore,
log: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
};
}

View File

@@ -95,6 +95,43 @@ const sessionHook = (action: string): SessionHookEvent | undefined =>
return event?.type === "session" && event.action === action;
})?.[0] as SessionHookEvent | undefined;
async function runCompactionHooks(params: { sessionKey?: string; messageProvider?: string }) {
const originalMessages = sessionMessages.slice(1) as AgentMessage[];
const currentMessages = sessionMessages.slice(1) as AgentMessage[];
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
originalMessages,
currentMessages,
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
});
const hookState = await compactTesting.runBeforeCompactionHooks({
hookRunner,
sessionId: TEST_SESSION_ID,
sessionKey: params.sessionKey,
sessionAgentId: "main",
workspaceDir: TEST_WORKSPACE_DIR,
messageProvider: params.messageProvider,
metrics: beforeMetrics,
});
await compactTesting.runAfterCompactionHooks({
hookRunner,
sessionId: TEST_SESSION_ID,
sessionAgentId: "main",
hookSessionKey: hookState.hookSessionKey,
missingSessionKey: hookState.missingSessionKey,
workspaceDir: TEST_WORKSPACE_DIR,
messageProvider: params.messageProvider,
messageCountAfter: 1,
tokensAfter: 10,
compactedCount: 1,
sessionFile: TEST_SESSION_FILE,
summaryLength: "summary".length,
tokensBefore: 120,
firstKeptEntryId: "entry-1",
});
}
beforeAll(async () => {
const loaded = await loadCompactHooksHarness();
compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect;
@@ -174,37 +211,9 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
it("emits internal + plugin compaction hooks with counts", async () => {
hookRunner.hasHooks.mockReturnValue(true);
const originalMessages = sessionMessages.slice(1) as AgentMessage[];
const currentMessages = sessionMessages.slice(1) as AgentMessage[];
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
originalMessages,
currentMessages,
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
});
const { hookSessionKey, missingSessionKey } = await compactTesting.runBeforeCompactionHooks({
hookRunner,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionAgentId: "main",
workspaceDir: "/tmp",
await runCompactionHooks({
sessionKey: TEST_SESSION_KEY,
messageProvider: "telegram",
metrics: beforeMetrics,
});
await compactTesting.runAfterCompactionHooks({
hookRunner,
sessionId: "session-1",
sessionAgentId: "main",
hookSessionKey,
missingSessionKey,
workspaceDir: "/tmp",
messageProvider: "telegram",
messageCountAfter: 1,
tokensAfter: 10,
compactedCount: 1,
sessionFile: "/tmp/session.jsonl",
summaryLength: "summary".length,
tokensBefore: 120,
firstKeptEntryId: "entry-1",
});
expect(sessionHook("compact:before")).toMatchObject({
@@ -248,32 +257,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
it("uses sessionId as hook session key fallback when sessionKey is missing", async () => {
hookRunner.hasHooks.mockReturnValue(true);
const originalMessages = sessionMessages.slice(1) as AgentMessage[];
const currentMessages = sessionMessages.slice(1) as AgentMessage[];
const beforeMetrics = compactTesting.buildBeforeCompactionHookMetrics({
originalMessages,
currentMessages,
estimateTokensFn: estimateTokensMock as (message: AgentMessage) => number,
});
const { hookSessionKey, missingSessionKey } = await compactTesting.runBeforeCompactionHooks({
hookRunner,
sessionId: "session-1",
sessionAgentId: "main",
workspaceDir: "/tmp",
metrics: beforeMetrics,
});
await compactTesting.runAfterCompactionHooks({
hookRunner,
sessionId: "session-1",
sessionAgentId: "main",
hookSessionKey,
missingSessionKey,
workspaceDir: "/tmp",
messageCountAfter: 1,
tokensAfter: 10,
compactedCount: 1,
sessionFile: "/tmp/session.jsonl",
});
await runCompactionHooks({});
expect(sessionHook("compact:before")?.sessionKey).toBe("session-1");
expect(sessionHook("compact:after")?.sessionKey).toBe("session-1");

View File

@@ -34,43 +34,6 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
resetRunOverflowCompactionHarnessMocks();
});
beforeEach(() => {
mockedRunEmbeddedAttempt.mockReset();
mockedRunContextEngineMaintenance.mockReset();
mockedCompactDirect.mockReset();
mockedCoerceToFailoverError.mockReset();
mockedDescribeFailoverError.mockReset();
mockedResolveFailoverStatus.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
mockedGlobalHookRunner.runAfterCompaction.mockReset();
mockedPickFallbackThinkingLevel.mockReset();
mockedContextEngine.info.ownsCompaction = false;
mockedCompactDirect.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
});
mockedRunContextEngineMaintenance.mockResolvedValue(undefined);
mockedCoerceToFailoverError.mockReturnValue(null);
mockedDescribeFailoverError.mockImplementation((err: unknown) => ({
message: err instanceof Error ? err.message : String(err),
reason: undefined,
status: undefined,
code: undefined,
}));
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
});
mockedPickFallbackThinkingLevel.mockReturnValue(null);
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
it("passes precomputed legacy before_agent_start result into the attempt", async () => {
const legacyResult = {
modelOverride: "legacy-model",

View File

@@ -2,19 +2,14 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { makeAttemptResult, makeCompactionSuccess } from "./run.overflow-compaction.fixture.js";
import {
loadRunOverflowCompactionHarness,
mockedCoerceToFailoverError,
mockedCompactDirect,
mockedContextEngine,
mockedDescribeFailoverError,
mockedGetApiKeyForModel,
mockedGlobalHookRunner,
mockedPickFallbackThinkingLevel,
mockedResolveAuthProfileOrder,
mockedResolveFailoverStatus,
mockedRunEmbeddedAttempt,
mockedRunPostCompactionSideEffects,
mockedSessionLikelyHasOversizedToolResults,
mockedTruncateOversizedToolResultsInSession,
overflowBaseRunParams,
resetRunOverflowCompactionHarnessMocks,
} from "./run.overflow-compaction.harness.js";
@@ -38,47 +33,6 @@ describe("timeout-triggered compaction", () => {
beforeEach(() => {
resetRunOverflowCompactionHarnessMocks();
mockedRunEmbeddedAttempt.mockReset();
mockedCompactDirect.mockReset();
mockedCoerceToFailoverError.mockReset();
mockedDescribeFailoverError.mockReset();
mockedResolveFailoverStatus.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
mockedGlobalHookRunner.runAfterCompaction.mockReset();
mockedPickFallbackThinkingLevel.mockReset();
mockedRunPostCompactionSideEffects.mockReset();
mockedRunPostCompactionSideEffects.mockResolvedValue(undefined);
mockedContextEngine.info.ownsCompaction = false;
mockedCompactDirect.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
});
mockedCoerceToFailoverError.mockReturnValue(null);
mockedDescribeFailoverError.mockImplementation((err: unknown) => ({
message: err instanceof Error ? err.message : String(err),
reason: undefined,
status: undefined,
code: undefined,
}));
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
});
mockedPickFallbackThinkingLevel.mockReturnValue(null);
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
mockedGetApiKeyForModel.mockImplementation(async ({ profileId } = {}) => ({
apiKey: "test-key",
profileId: profileId ?? "test-profile",
source: "test",
mode: "api-key",
}));
mockedResolveAuthProfileOrder.mockReturnValue([]);
});
it("attempts compaction when LLM times out with high prompt token usage (>65%)", async () => {