mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 14:11:26 +00:00
fix(test): reduce startup-heavy hotspot retention (#52381)
This commit is contained in:
@@ -7,9 +7,14 @@ const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
@@ -58,6 +63,7 @@ describe("feishu plugin register", () => {
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock });
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Server } from "node:http";
|
||||
import { createConnection, type AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMSTeamsWebhookTimeouts } from "./monitor.js";
|
||||
import { applyMSTeamsWebhookTimeouts } from "./webhook-timeouts.js";
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Server } from "node:http";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES,
|
||||
@@ -21,6 +20,10 @@ import {
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
applyMSTeamsWebhookTimeouts,
|
||||
type ApplyMSTeamsWebhookTimeoutsOpts,
|
||||
} from "./webhook-timeouts.js";
|
||||
|
||||
export type MonitorMSTeamsOpts = {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -36,32 +39,6 @@ export type MonitorMSTeamsResult = {
|
||||
};
|
||||
|
||||
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
||||
const MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type ApplyMSTeamsWebhookTimeoutsOpts = {
|
||||
inactivityTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
headersTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export function applyMSTeamsWebhookTimeouts(
|
||||
httpServer: Server,
|
||||
opts?: ApplyMSTeamsWebhookTimeoutsOpts,
|
||||
): void {
|
||||
const inactivityTimeoutMs = opts?.inactivityTimeoutMs ?? MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS;
|
||||
const requestTimeoutMs = opts?.requestTimeoutMs ?? MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS;
|
||||
const headersTimeoutMs = Math.min(
|
||||
opts?.headersTimeoutMs ?? MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS,
|
||||
requestTimeoutMs,
|
||||
);
|
||||
|
||||
httpServer.setTimeout(inactivityTimeoutMs);
|
||||
httpServer.requestTimeout = requestTimeoutMs;
|
||||
httpServer.headersTimeout = headersTimeoutMs;
|
||||
}
|
||||
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
|
||||
27
extensions/msteams/src/webhook-timeouts.ts
Normal file
27
extensions/msteams/src/webhook-timeouts.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type ApplyMSTeamsWebhookTimeoutsOpts = {
|
||||
inactivityTimeoutMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
headersTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export function applyMSTeamsWebhookTimeouts(
|
||||
httpServer: Server,
|
||||
opts?: ApplyMSTeamsWebhookTimeoutsOpts,
|
||||
): void {
|
||||
const inactivityTimeoutMs = opts?.inactivityTimeoutMs ?? MSTEAMS_WEBHOOK_INACTIVITY_TIMEOUT_MS;
|
||||
const requestTimeoutMs = opts?.requestTimeoutMs ?? MSTEAMS_WEBHOOK_REQUEST_TIMEOUT_MS;
|
||||
const headersTimeoutMs = Math.min(
|
||||
opts?.headersTimeoutMs ?? MSTEAMS_WEBHOOK_HEADERS_TIMEOUT_MS,
|
||||
requestTimeoutMs,
|
||||
);
|
||||
|
||||
httpServer.setTimeout(inactivityTimeoutMs);
|
||||
httpServer.requestTimeout = requestTimeoutMs;
|
||||
httpServer.headersTimeout = headersTimeoutMs;
|
||||
}
|
||||
@@ -54,6 +54,9 @@ const unitBehaviorIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.
|
||||
const unitSingletonIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.singletonIsolated);
|
||||
const unitThreadSingletonFiles = existingUnitConfigFiles(behaviorManifest.unit.threadSingleton);
|
||||
const unitVmForkSingletonFiles = existingUnitConfigFiles(behaviorManifest.unit.vmForkSingleton);
|
||||
const extensionSingletonIsolatedFiles = existingFiles(
|
||||
behaviorManifest.extensions.singletonIsolated,
|
||||
);
|
||||
const unitBehaviorOverrideSet = new Set([
|
||||
...unitBehaviorIsolatedFiles,
|
||||
...unitSingletonIsolatedFiles,
|
||||
@@ -440,6 +443,10 @@ const unitFastExcludedFileSet = new Set(unitFastExcludedFiles);
|
||||
const unitFastCandidateFiles = allKnownUnitFiles.filter(
|
||||
(file) => !unitFastExcludedFileSet.has(file),
|
||||
);
|
||||
const extensionSingletonExcludedFileSet = new Set(extensionSingletonIsolatedFiles);
|
||||
const extensionSharedCandidateFiles = allKnownTestFiles.filter(
|
||||
(file) => file.startsWith("extensions/") && !extensionSingletonExcludedFileSet.has(file),
|
||||
);
|
||||
const defaultUnitFastLaneCount = isCI && !isWindows ? 3 : 1;
|
||||
const unitFastLaneCount = Math.max(
|
||||
1,
|
||||
@@ -516,6 +523,10 @@ const unitThreadEntries =
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const extensionSingletonEntries = extensionSingletonIsolatedFiles.map((file) => ({
|
||||
name: `${path.basename(file, ".test.ts")}-extensions-isolated`,
|
||||
args: ["vitest", "run", "--config", "vitest.extensions.config.ts", "--pool=forks", file],
|
||||
}));
|
||||
const baseRuns = [
|
||||
...(shouldSplitUnitRuns
|
||||
? [
|
||||
@@ -583,6 +594,15 @@ const baseRuns = [
|
||||
? [
|
||||
{
|
||||
name: "extensions",
|
||||
env:
|
||||
extensionSharedCandidateFiles.length > 0
|
||||
? {
|
||||
OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact(
|
||||
"vitest-extensions-include",
|
||||
extensionSharedCandidateFiles,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
args: [
|
||||
"vitest",
|
||||
"run",
|
||||
@@ -591,6 +611,7 @@ const baseRuns = [
|
||||
...(useVmForks ? ["--pool=vmForks"] : []),
|
||||
],
|
||||
},
|
||||
...extensionSingletonEntries,
|
||||
]
|
||||
: []),
|
||||
...(includeGatewaySuite
|
||||
|
||||
@@ -31,6 +31,7 @@ export function loadTestRunnerBehavior() {
|
||||
const raw = tryReadJsonFile(behaviorManifestPath, {});
|
||||
const unit = raw.unit ?? {};
|
||||
const base = raw.base ?? {};
|
||||
const extensions = raw.extensions ?? {};
|
||||
return {
|
||||
base: {
|
||||
threadSingleton: normalizeManifestEntries(base.threadSingleton ?? []),
|
||||
@@ -41,6 +42,9 @@ export function loadTestRunnerBehavior() {
|
||||
threadSingleton: normalizeManifestEntries(unit.threadSingleton ?? []),
|
||||
vmForkSingleton: normalizeManifestEntries(unit.vmForkSingleton ?? []),
|
||||
},
|
||||
extensions: {
|
||||
singletonIsolated: normalizeManifestEntries(extensions.singletonIsolated ?? []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +1,38 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeSendAction: vi.fn(),
|
||||
recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
prepareOutboundMirrorRoute,
|
||||
resolveAndApplyOutboundThreadId,
|
||||
} from "./message-action-threading.js";
|
||||
|
||||
vi.mock("./outbound-send-service.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./outbound-send-service.js")>(
|
||||
"./outbound-send-service.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
executeSendAction: mocks.executeSendAction,
|
||||
};
|
||||
});
|
||||
const ensureOutboundSessionEntry = vi.fn(async () => undefined);
|
||||
const resolveOutboundSessionRoute = vi.fn();
|
||||
|
||||
vi.mock("../../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
|
||||
"../../config/sessions.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound,
|
||||
};
|
||||
});
|
||||
const slackConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
type MessageActionRunnerModule = typeof import("./message-action-runner.js");
|
||||
type MessageActionRunnerTestHelpersModule =
|
||||
typeof import("./message-action-runner.test-helpers.js");
|
||||
|
||||
let runMessageAction: MessageActionRunnerModule["runMessageAction"];
|
||||
let installMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["installMessageActionRunnerTestRegistry"];
|
||||
let resetMessageActionRunnerTestRegistry: MessageActionRunnerTestHelpersModule["resetMessageActionRunnerTestRegistry"];
|
||||
let slackConfig: MessageActionRunnerTestHelpersModule["slackConfig"];
|
||||
let telegramConfig: MessageActionRunnerTestHelpersModule["telegramConfig"];
|
||||
|
||||
async function runThreadingAction(params: {
|
||||
cfg: MessageActionRunnerTestHelpersModule["slackConfig"];
|
||||
actionParams: Record<string, unknown>;
|
||||
toolContext?: Record<string, unknown>;
|
||||
}) {
|
||||
await runMessageAction({
|
||||
cfg: params.cfg,
|
||||
action: "send",
|
||||
params: params.actionParams as never,
|
||||
toolContext: params.toolContext as never,
|
||||
agentId: "main",
|
||||
});
|
||||
return mocks.executeSendAction.mock.calls[0]?.[0] as {
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
ctx?: { agentId?: string; mirror?: { sessionKey?: string }; params?: Record<string, unknown> };
|
||||
};
|
||||
}
|
||||
|
||||
function mockHandledSendAction() {
|
||||
mocks.executeSendAction.mockResolvedValue({
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
const telegramConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const defaultTelegramToolContext = {
|
||||
currentChannelId: "telegram:123",
|
||||
currentThreadTs: "42",
|
||||
} as const;
|
||||
|
||||
describe("runMessageAction threading auto-injection", () => {
|
||||
beforeAll(async () => {
|
||||
({ runMessageAction } = await import("./message-action-runner.js"));
|
||||
({
|
||||
installMessageActionRunnerTestRegistry,
|
||||
resetMessageActionRunnerTestRegistry,
|
||||
slackConfig,
|
||||
telegramConfig,
|
||||
} = await import("./message-action-runner.test-helpers.js"));
|
||||
});
|
||||
|
||||
describe("message action threading helpers", () => {
|
||||
beforeEach(() => {
|
||||
installMessageActionRunnerTestRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetMessageActionRunnerTestRegistry?.();
|
||||
mocks.executeSendAction.mockClear();
|
||||
mocks.recordSessionMetaFromInbound.mockClear();
|
||||
ensureOutboundSessionEntry.mockClear();
|
||||
resolveOutboundSessionRoute.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -99,25 +48,42 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
threadTs: "333.444",
|
||||
expectedSessionKey: "agent:main:slack:channel:c123:thread:333.444",
|
||||
},
|
||||
] as const)("auto-threads slack using $name", async (testCase) => {
|
||||
mockHandledSendAction();
|
||||
] as const)("prepares outbound routes for slack using $name", async (testCase) => {
|
||||
const actionParams: Record<string, unknown> = {
|
||||
channel: "slack",
|
||||
target: testCase.target,
|
||||
message: "hi",
|
||||
};
|
||||
resolveOutboundSessionRoute.mockResolvedValue({
|
||||
sessionKey: testCase.expectedSessionKey,
|
||||
baseSessionKey: "base",
|
||||
peer: { id: "peer", kind: "channel" },
|
||||
chatType: "channel",
|
||||
from: "from",
|
||||
to: testCase.target,
|
||||
threadId: testCase.threadTs,
|
||||
});
|
||||
|
||||
const call = await runThreadingAction({
|
||||
const result = await prepareOutboundMirrorRoute({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: testCase.target,
|
||||
message: "hi",
|
||||
},
|
||||
channel: "slack",
|
||||
to: testCase.target,
|
||||
actionParams,
|
||||
toolContext: {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: testCase.threadTs,
|
||||
replyToMode: "all",
|
||||
},
|
||||
agentId: "main",
|
||||
resolveAutoThreadId: ({ toolContext }) => toolContext?.currentThreadTs,
|
||||
resolveOutboundSessionRoute,
|
||||
ensureOutboundSessionEntry,
|
||||
});
|
||||
|
||||
expect(call?.ctx?.agentId).toBe("main");
|
||||
expect(call?.ctx?.mirror?.sessionKey).toBe(testCase.expectedSessionKey);
|
||||
expect(result.outboundRoute?.sessionKey).toBe(testCase.expectedSessionKey);
|
||||
expect(actionParams.__sessionKey).toBe(testCase.expectedSessionKey);
|
||||
expect(actionParams.__agentId).toBe("main");
|
||||
expect(ensureOutboundSessionEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -136,58 +102,66 @@ describe("runMessageAction threading auto-injection", () => {
|
||||
target: "telegram:999",
|
||||
expectedThreadId: undefined,
|
||||
},
|
||||
] as const)("telegram auto-threading: $name", async (testCase) => {
|
||||
mockHandledSendAction();
|
||||
] as const)("telegram auto-threading: $name", (testCase) => {
|
||||
const actionParams: Record<string, unknown> = {
|
||||
channel: "telegram",
|
||||
target: testCase.target,
|
||||
message: "hi",
|
||||
};
|
||||
|
||||
const call = await runThreadingAction({
|
||||
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
|
||||
cfg: telegramConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: testCase.target,
|
||||
message: "hi",
|
||||
},
|
||||
to: testCase.target,
|
||||
toolContext: defaultTelegramToolContext,
|
||||
resolveAutoThreadId: ({ to, toolContext }) =>
|
||||
to.includes("123") ? toolContext?.currentThreadTs : undefined,
|
||||
});
|
||||
|
||||
expect(call?.ctx?.params?.threadId).toBe(testCase.expectedThreadId);
|
||||
if (testCase.expectedThreadId !== undefined) {
|
||||
expect(call?.threadId).toBe(testCase.expectedThreadId);
|
||||
}
|
||||
expect(actionParams.threadId).toBe(testCase.expectedThreadId);
|
||||
expect(resolved).toBe(testCase.expectedThreadId);
|
||||
});
|
||||
|
||||
it("uses explicit telegram threadId when provided", async () => {
|
||||
mockHandledSendAction();
|
||||
it("uses explicit telegram threadId when provided", () => {
|
||||
const actionParams: Record<string, unknown> = {
|
||||
channel: "telegram",
|
||||
target: "telegram:123",
|
||||
message: "hi",
|
||||
threadId: "999",
|
||||
};
|
||||
|
||||
const call = await runThreadingAction({
|
||||
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
|
||||
cfg: telegramConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: "telegram:123",
|
||||
message: "hi",
|
||||
threadId: "999",
|
||||
},
|
||||
to: "telegram:123",
|
||||
toolContext: defaultTelegramToolContext,
|
||||
resolveAutoThreadId: () => "42",
|
||||
});
|
||||
|
||||
expect(call?.threadId).toBe("999");
|
||||
expect(call?.ctx?.params?.threadId).toBe("999");
|
||||
expect(actionParams.threadId).toBe("999");
|
||||
expect(resolved).toBe("999");
|
||||
});
|
||||
|
||||
it("threads explicit replyTo through executeSendAction", async () => {
|
||||
mockHandledSendAction();
|
||||
it("passes explicit replyTo into auto-thread resolution", () => {
|
||||
const resolveAutoThreadId = vi.fn(() => "thread-777");
|
||||
const actionParams: Record<string, unknown> = {
|
||||
channel: "telegram",
|
||||
target: "telegram:123",
|
||||
message: "hi",
|
||||
replyTo: "777",
|
||||
};
|
||||
|
||||
const call = await runThreadingAction({
|
||||
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
|
||||
cfg: telegramConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: "telegram:123",
|
||||
message: "hi",
|
||||
replyTo: "777",
|
||||
},
|
||||
to: "telegram:123",
|
||||
toolContext: defaultTelegramToolContext,
|
||||
resolveAutoThreadId,
|
||||
});
|
||||
|
||||
expect(call?.replyToId).toBe("777");
|
||||
expect(call?.ctx?.params?.replyTo).toBe("777");
|
||||
expect(resolveAutoThreadId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToId: "777",
|
||||
}),
|
||||
);
|
||||
expect(resolved).toBe("thread-777");
|
||||
expect(actionParams.threadId).toBe("thread-777");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
readBooleanParam,
|
||||
resolveAttachmentMediaPolicy,
|
||||
} from "./message-action-params.js";
|
||||
import {
|
||||
prepareOutboundMirrorRoute,
|
||||
resolveAndApplyOutboundThreadId,
|
||||
} from "./message-action-threading.js";
|
||||
import type { MessagePollResult, MessageSendResult } from "./message.js";
|
||||
import {
|
||||
applyCrossContextDecoration,
|
||||
@@ -62,34 +66,6 @@ export type MessageActionRunnerGateway = {
|
||||
mode: GatewayClientMode;
|
||||
};
|
||||
|
||||
function resolveAndApplyOutboundThreadId(
|
||||
params: Record<string, unknown>,
|
||||
ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
},
|
||||
): string | undefined {
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const resolved =
|
||||
threadId ??
|
||||
getChannelPlugin(ctx.channel)?.threading?.resolveAutoThreadId?.({
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId,
|
||||
to: ctx.to,
|
||||
toolContext: ctx.toolContext,
|
||||
replyToId: readStringParam(params, "replyTo"),
|
||||
});
|
||||
// Write auto-resolved threadId back into params so downstream dispatch
|
||||
// (plugin `readStringParam(params, "threadId")`) picks it up.
|
||||
if (resolved && !params.threadId) {
|
||||
params.threadId = resolved;
|
||||
}
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
export type RunMessageActionParams = {
|
||||
cfg: OpenClawConfig;
|
||||
action: ChannelMessageActionName;
|
||||
@@ -501,41 +477,20 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
|
||||
const replyToId = readStringParam(params, "replyTo");
|
||||
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
|
||||
const { resolvedThreadId, outboundRoute } = await prepareOutboundMirrorRoute({
|
||||
cfg,
|
||||
channel,
|
||||
to,
|
||||
actionParams: params,
|
||||
accountId,
|
||||
toolContext: input.toolContext,
|
||||
agentId,
|
||||
dryRun,
|
||||
resolvedTarget,
|
||||
resolveAutoThreadId: getChannelPlugin(channel)?.threading?.resolveAutoThreadId,
|
||||
resolveOutboundSessionRoute,
|
||||
ensureOutboundSessionEntry,
|
||||
});
|
||||
const outboundRoute =
|
||||
agentId && !dryRun
|
||||
? await resolveOutboundSessionRoute({
|
||||
cfg,
|
||||
channel,
|
||||
agentId,
|
||||
accountId,
|
||||
target: to,
|
||||
resolvedTarget,
|
||||
replyToId,
|
||||
threadId: resolvedThreadId,
|
||||
})
|
||||
: null;
|
||||
if (outboundRoute && agentId && !dryRun) {
|
||||
await ensureOutboundSessionEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
channel,
|
||||
accountId,
|
||||
route: outboundRoute,
|
||||
});
|
||||
}
|
||||
if (outboundRoute && !dryRun) {
|
||||
params.__sessionKey = outboundRoute.sessionKey;
|
||||
}
|
||||
if (agentId) {
|
||||
params.__agentId = agentId;
|
||||
}
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
throwIfAborted(abortSignal);
|
||||
@@ -595,10 +550,10 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
|
||||
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
|
||||
cfg,
|
||||
channel,
|
||||
to,
|
||||
accountId,
|
||||
toolContext: input.toolContext,
|
||||
resolveAutoThreadId: getChannelPlugin(channel)?.threading?.resolveAutoThreadId,
|
||||
});
|
||||
|
||||
const base = typeof params.message === "string" ? params.message : "";
|
||||
|
||||
107
src/infra/outbound/message-action-threading.ts
Normal file
107
src/infra/outbound/message-action-threading.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { readStringParam } from "../../agents/tools/common.js";
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelThreadingAdapter,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
OutboundSessionRoute,
|
||||
ResolveOutboundSessionRouteParams,
|
||||
} from "./outbound-session.js";
|
||||
import type { ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
|
||||
type ResolveAutoThreadId = NonNullable<ChannelThreadingAdapter["resolveAutoThreadId"]>;
|
||||
|
||||
export function resolveAndApplyOutboundThreadId(
|
||||
actionParams: Record<string, unknown>,
|
||||
context: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
resolveAutoThreadId?: ResolveAutoThreadId;
|
||||
},
|
||||
): string | undefined {
|
||||
const threadId = readStringParam(actionParams, "threadId");
|
||||
const resolved =
|
||||
threadId ??
|
||||
context.resolveAutoThreadId?.({
|
||||
cfg: context.cfg,
|
||||
accountId: context.accountId,
|
||||
to: context.to,
|
||||
toolContext: context.toolContext,
|
||||
replyToId: readStringParam(actionParams, "replyTo"),
|
||||
});
|
||||
if (resolved && !actionParams.threadId) {
|
||||
actionParams.threadId = resolved;
|
||||
}
|
||||
return resolved ?? undefined;
|
||||
}
|
||||
|
||||
export async function prepareOutboundMirrorRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
to: string;
|
||||
actionParams: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
resolveAutoThreadId?: ResolveAutoThreadId;
|
||||
resolveOutboundSessionRoute: (
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
) => Promise<OutboundSessionRoute | null>;
|
||||
ensureOutboundSessionEntry: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
channel: ChannelId;
|
||||
accountId?: string | null;
|
||||
route: OutboundSessionRoute;
|
||||
}) => Promise<void>;
|
||||
}): Promise<{
|
||||
resolvedThreadId?: string;
|
||||
outboundRoute: OutboundSessionRoute | null;
|
||||
}> {
|
||||
const replyToId = readStringParam(params.actionParams, "replyTo");
|
||||
const resolvedThreadId = resolveAndApplyOutboundThreadId(params.actionParams, {
|
||||
cfg: params.cfg,
|
||||
to: params.to,
|
||||
accountId: params.accountId,
|
||||
toolContext: params.toolContext,
|
||||
resolveAutoThreadId: params.resolveAutoThreadId,
|
||||
});
|
||||
const outboundRoute =
|
||||
params.agentId && !params.dryRun
|
||||
? await params.resolveOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
target: params.to,
|
||||
resolvedTarget: params.resolvedTarget,
|
||||
replyToId,
|
||||
threadId: resolvedThreadId,
|
||||
})
|
||||
: null;
|
||||
if (outboundRoute && params.agentId && !params.dryRun) {
|
||||
await params.ensureOutboundSessionEntry({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
route: outboundRoute,
|
||||
});
|
||||
}
|
||||
if (outboundRoute && !params.dryRun) {
|
||||
params.actionParams.__sessionKey = outboundRoute.sessionKey;
|
||||
}
|
||||
if (params.agentId) {
|
||||
params.actionParams.__agentId = params.agentId;
|
||||
}
|
||||
return {
|
||||
resolvedThreadId,
|
||||
outboundRoute,
|
||||
};
|
||||
}
|
||||
20
test/fixtures/test-parallel.behavior.json
vendored
20
test/fixtures/test-parallel.behavior.json
vendored
@@ -39,6 +39,10 @@
|
||||
"file": "src/cli/command-secret-gateway.test.ts",
|
||||
"reason": "Clean in isolation, but can hang after sharing the broad lane."
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/provider-wizard.test.ts",
|
||||
"reason": "Retained ~318 MiB in an isolated memory trace and is safer outside the shared unit-fast lane."
|
||||
},
|
||||
{
|
||||
"file": "src/config/doc-baseline.integration.test.ts",
|
||||
"reason": "Builds the full bundled config schema graph and is safer outside the shared unit-fast heap."
|
||||
@@ -984,5 +988,21 @@
|
||||
"reason": "Needs the vmForks lane when targeted."
|
||||
}
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"singletonIsolated": [
|
||||
{
|
||||
"file": "extensions/bluebubbles/src/setup-surface.test.ts",
|
||||
"reason": "Loads the setup-wizard/plugin-sdk graph and retained ~576 MiB in an isolated memory trace."
|
||||
},
|
||||
{
|
||||
"file": "extensions/feishu/index.test.ts",
|
||||
"reason": "Plugin entry registration coverage loads a broad channel graph and is safer outside the shared extensions lane."
|
||||
},
|
||||
{
|
||||
"file": "extensions/msteams/src/monitor.test.ts",
|
||||
"reason": "Webhook timeout coverage is startup-heavy but showed no material retained heap growth across repeated snapshots."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
55
test/vitest-extensions-config.test.ts
Normal file
55
test/vitest-extensions-config.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { loadIncludePatternsFromEnv } from "../vitest.extensions.config.ts";
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
const writePatternFile = (basename: string, value: unknown) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-extensions-config-"));
|
||||
tempDirs.add(dir);
|
||||
const filePath = path.join(dir, basename);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||||
return filePath;
|
||||
};
|
||||
|
||||
describe("extensions vitest include patterns", () => {
|
||||
it("returns null when no include file is configured", () => {
|
||||
expect(loadIncludePatternsFromEnv({})).toBeNull();
|
||||
});
|
||||
|
||||
it("loads include patterns from a JSON file", () => {
|
||||
const filePath = writePatternFile("include.json", [
|
||||
"extensions/feishu/index.test.ts",
|
||||
42,
|
||||
"",
|
||||
"extensions/msteams/src/monitor.test.ts",
|
||||
]);
|
||||
|
||||
expect(
|
||||
loadIncludePatternsFromEnv({
|
||||
OPENCLAW_VITEST_INCLUDE_FILE: filePath,
|
||||
}),
|
||||
).toEqual(["extensions/feishu/index.test.ts", "extensions/msteams/src/monitor.test.ts"]);
|
||||
});
|
||||
|
||||
it("throws when the configured file is not a JSON array", () => {
|
||||
const filePath = writePatternFile("include.json", {
|
||||
include: ["extensions/feishu/index.test.ts"],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
loadIncludePatternsFromEnv({
|
||||
OPENCLAW_VITEST_INCLUDE_FILE: filePath,
|
||||
}),
|
||||
).toThrow(/JSON array/u);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,31 @@
|
||||
import fs from "node:fs";
|
||||
import { channelTestExclude } from "./vitest.channel-paths.mjs";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
export default createScopedVitestConfig(["extensions/**/*.test.ts"], {
|
||||
// Channel implementations live under extensions/ but are tested by
|
||||
// vitest.channels.config.ts (pnpm test:channels) which provides
|
||||
// the heavier mock scaffolding they need.
|
||||
exclude: channelTestExclude.filter((pattern) => pattern.startsWith("extensions/")),
|
||||
});
|
||||
function loadPatternListFile(filePath: string, label: string): string[] {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new TypeError(`${label} must point to a JSON array: ${filePath}`);
|
||||
}
|
||||
return parsed.filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
export function loadIncludePatternsFromEnv(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string[] | null {
|
||||
const includeFile = env.OPENCLAW_VITEST_INCLUDE_FILE?.trim();
|
||||
if (!includeFile) {
|
||||
return null;
|
||||
}
|
||||
return loadPatternListFile(includeFile, "OPENCLAW_VITEST_INCLUDE_FILE");
|
||||
}
|
||||
|
||||
export default createScopedVitestConfig(
|
||||
loadIncludePatternsFromEnv() ?? ["extensions/**/*.test.ts"],
|
||||
{
|
||||
// Channel implementations live under extensions/ but are tested by
|
||||
// vitest.channels.config.ts (pnpm test:channels) which provides
|
||||
// the heavier mock scaffolding they need.
|
||||
exclude: channelTestExclude.filter((pattern) => pattern.startsWith("extensions/")),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user