Files
openclaw/extensions/googlechat/src/monitor.test.ts
Peter Steinberger 1507a9701b refactor: centralize inbound supplemental context
* refactor: centralize inbound supplemental context

* refactor: trim supplemental finalizer typing

* docs: clarify supplemental context projection

* refactor: move inbound finalization into core

* refactor: simplify channel inbound facts

* refactor: fold supplemental media into inbound finalizer

* refactor: migrate channel inbound callers to builder

* docs: mark inbound finalizer compat types deprecated

* refactor: wire runtime turn context builder

* refactor: replace channel turn runtime API

* fix: respect discord quote visibility

* fix: avoid deprecated line dispatch helper

* refactor: deprecate channel message SDK seams

* docs: trim channel outbound SDK page

* test: migrate irc inbound assertion

* refactor: deprecate outbound SDK facades

* refactor: deprecate channel helper SDK facades

* refactor: deprecate channel streaming SDK facade

* refactor: move direct dm helpers into inbound SDK

* chore: mark legacy test-utils SDK alias deprecated

* refactor: remove unused allow-from read helper

* refactor: route remaining channel dispatch through core

* refactor: enforce modern extension SDK imports

* test: give slow image root tests more time

* ci: support node fallback on windows

* fix: add transcripts tool display metadata

* refactor: trim legacy channel test seams

* fix: preserve channel compat after rebase

* fix: keep deprecated channel inbound aliases

* fix: preserve discord thread context visibility

* fix: clean final rebase conflicts

* fix: preserve channel message dispatch aliases

* fix: sync channel refactor after rebase

* fix: sync channel refactor after latest main

* fix: dedupe memory-core subagent mock

* test: align clickclack inbound dispatch assertions

* fix: sync plugin sdk api hash after rebase

* fix: sync channel refactor after latest main

* fix: sync plugin sdk api hash after rebase

* fix: sync plugin sdk api hash after latest main

* test: remove stale inbound context awaits
2026-05-27 09:26:06 +01:00

247 lines
7.5 KiB
TypeScript

import { recordChannelBotPairLoopAndCheckSuppression } from "openclaw/plugin-sdk/channel-inbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
import { testing } from "./monitor.js";
import type { GoogleChatEvent } from "./types.js";
const apiMocks = vi.hoisted(() => ({
downloadGoogleChatMedia: vi.fn(),
sendGoogleChatMessage: vi.fn(),
}));
const accessMocks = vi.hoisted(() => ({
applyGoogleChatInboundAccessPolicy: vi.fn(),
}));
vi.mock("./api.js", () => ({
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
}));
vi.mock("./monitor-access.js", () => ({
applyGoogleChatInboundAccessPolicy: accessMocks.applyGoogleChatInboundAccessPolicy,
}));
beforeEach(() => {
apiMocks.downloadGoogleChatMedia.mockReset();
apiMocks.sendGoogleChatMessage.mockReset();
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
});
describe("googlechat monitor bot loop protection", () => {
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
expect(
testing.resolveGoogleChatBotLoopProtection({
allowBots: true,
isBotSender: true,
senderId: "users/other-bot",
appUserId: "users/app-bot",
accountId: "work",
conversationId: "spaces/AAA",
config: { maxEventsPerWindow: 3 },
defaultsConfig: { maxEventsPerWindow: 20 },
eventTime: "2026-03-22T00:00:00.000Z",
}),
).toEqual({
scopeId: "work",
conversationId: "spaces/AAA",
senderId: "users/other-bot",
receiverId: "users/app-bot",
config: { maxEventsPerWindow: 3 },
defaultsConfig: { maxEventsPerWindow: 20 },
defaultEnabled: true,
nowMs: Date.parse("2026-03-22T00:00:00.000Z"),
});
});
it("does not guard human messages or the app's own echo", () => {
expect(
testing.resolveGoogleChatBotLoopProtection({
allowBots: true,
isBotSender: false,
senderId: "users/alice",
appUserId: "users/app",
accountId: "work",
conversationId: "spaces/AAA",
}),
).toBeUndefined();
expect(
testing.resolveGoogleChatBotLoopProtection({
allowBots: true,
isBotSender: true,
senderId: "users/app",
appUserId: "users/app",
accountId: "work",
conversationId: "spaces/AAA",
}),
).toBeUndefined();
});
it("layers space bot loop overrides over account settings field-by-field", () => {
expect(
testing.resolveGoogleChatBotLoopProtectionConfig({
accountConfig: { windowSeconds: 120, cooldownSeconds: 240 },
groupConfig: { maxEventsPerWindow: 3 },
}),
).toEqual({
maxEventsPerWindow: 3,
windowSeconds: 120,
cooldownSeconds: 240,
});
});
it("suppresses bot loops before creating typing messages", async () => {
const eventTimeMs = Date.parse("2026-03-22T00:00:00.000Z");
const accountId = `bot-loop-typing-${eventTimeMs}`;
const conversationId = "spaces/LOOP";
const senderId = "users/other-bot";
const receiverId = "users/app";
const runTurn = vi.fn();
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
inbound: { run: runTurn },
},
} as unknown as GoogleChatCoreRuntime;
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
const account = {
accountId,
config: {
allowBots: true,
botUser: receiverId,
botLoopProtection: { maxEventsPerWindow: 1, windowSeconds: 60, cooldownSeconds: 60 },
typingIndicator: "message",
},
credentialSource: "inline",
} as ResolvedGoogleChatAccount;
const event = {
type: "MESSAGE",
eventTime: "2026-03-22T00:00:00.001Z",
space: { name: conversationId, type: "DM" },
message: {
name: "spaces/LOOP/messages/2",
text: "loop",
sender: { name: senderId, type: "BOT" },
},
} satisfies GoogleChatEvent;
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
ok: true,
commandAuthorized: undefined,
effectiveWasMentioned: undefined,
groupBotLoopProtection: undefined,
groupSystemPrompt: undefined,
});
recordChannelBotPairLoopAndCheckSuppression({
scopeId: accountId,
conversationId,
senderId,
receiverId,
config: account.config.botLoopProtection,
defaultEnabled: true,
nowMs: eventTimeMs,
});
await testing.processMessageWithPipeline({
event,
account,
config: {},
runtime,
core,
mediaMaxMb: 10,
});
expect(apiMocks.sendGoogleChatMessage).not.toHaveBeenCalled();
expect(apiMocks.downloadGoogleChatMedia).not.toHaveBeenCalled();
expect(runTurn).not.toHaveBeenCalled();
});
});
describe("googlechat monitor direct messages", () => {
it("omits thread metadata from DM reply context and typing messages", async () => {
const runTurn = vi.fn();
const buildContext = vi.fn((payload: unknown) => payload);
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
routing: {
resolveAgentRoute: () => ({
agentId: "agent-1",
accountId: "work",
sessionKey: "session-1",
}),
},
session: {
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
readSessionUpdatedAt: () => undefined,
recordInboundSession: vi.fn(),
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
inbound: { buildContext, run: runTurn },
},
} as unknown as GoogleChatCoreRuntime;
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
const account = {
accountId: "work",
config: {
typingIndicator: "message",
},
credentialSource: "inline",
} as ResolvedGoogleChatAccount;
const event = {
type: "MESSAGE",
eventTime: "2026-03-22T00:00:00.001Z",
space: { name: "spaces/DM", type: "DM" },
message: {
name: "spaces/DM/messages/2",
text: "hello",
thread: { name: "spaces/DM/threads/thread-1" },
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
},
} satisfies GoogleChatEvent;
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
ok: true,
commandAuthorized: undefined,
effectiveWasMentioned: undefined,
groupBotLoopProtection: undefined,
groupSystemPrompt: undefined,
});
apiMocks.sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/DM/messages/typing",
});
await testing.processMessageWithPipeline({
event,
account,
config: {},
runtime,
core,
mediaMaxMb: 10,
});
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({
reply: {
to: "googlechat:spaces/DM",
originatingTo: "googlechat:spaces/DM",
replyToId: undefined,
replyToIdFull: undefined,
},
}),
);
expect(apiMocks.sendGoogleChatMessage).toHaveBeenCalledWith({
account,
space: "spaces/DM",
text: "_OpenClaw is typing..._",
thread: undefined,
});
expect(runTurn).toHaveBeenCalledOnce();
});
});