Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Steinberger 0512059dd4 chore: prepare 2026.4.9 stable release
Docker Release / validate_manual_backfill (push) Has been cancelled
Docker Release / approve_manual_backfill (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
2026-04-09 03:24:45 +01:00
Peter Steinberger b5c3c15dcf test: keep local full suite serial by default 2026-04-09 03:23:00 +01:00
sudie-codes 1fed7bc379 fix(msteams): pin reply target at inbound time to prevent DM/channel leak (#54520) (#62716) 2026-04-08 21:22:12 -05:00
sudie-codes 9edfefedf7 fix(msteams): route thread replies to correct thread via replyToId (#58030) (#62715) 2026-04-08 21:22:09 -05:00
sudie-codes 38aa1edf76 fix(msteams): isolate channel thread sessions by replyToId (#58615) (#62713)
* fix(msteams): isolate thread sessions by replyToId (#58615)

* fix(msteams): align thread ID extraction + fix test types
2026-04-08 21:22:05 -05:00
Peter Steinberger 62bde7ede3 test: isolate slack thread-ts recovery 2026-04-09 02:43:29 +01:00
Peter Steinberger b27918007a ci: tolerate noisy npm pack json output 2026-04-09 02:42:03 +01:00
Mason 74b5b97f62 tests: reset discord native-command seams in model picker (#63267) 2026-04-09 09:38:31 +08:00
pashpashpash 0faae33b0c wizard: add explicit skip option to plugin setup (#63436)
* Wizard: allow skipping plugin setup

* Agents: reset nodes tool test modules
2026-04-09 10:35:39 +09:00
Peter Steinberger 5b28ab83ef test: run local full suite project shards in parallel 2026-04-09 02:26:22 +01:00
25 changed files with 969 additions and 128 deletions
+57 -3
View File
@@ -162,9 +162,63 @@ jobs:
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
PACK_JSON="$(npm pack --json)"
echo "$PACK_JSON"
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')"
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
npm pack --json 2>&1 | tee "$PACK_OUTPUT"
PACK_PATH="$(node - "$PACK_OUTPUT" <<'NODE'
const fs = require("node:fs");
const input = fs.readFileSync(process.argv[2], "utf8");
function arrayEndFrom(start) {
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < input.length; i += 1) {
const char = input[i];
if (inString) {
if (escape) {
escape = false;
} else if (char === "\\") {
escape = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
} else if (char === "[") {
depth += 1;
} else if (char === "]") {
depth -= 1;
if (depth === 0) {
return i + 1;
}
}
}
return -1;
}
for (let start = input.indexOf("["); start !== -1; start = input.indexOf("[", start + 1)) {
const end = arrayEndFrom(start);
if (end === -1) {
continue;
}
try {
const parsed = JSON.parse(input.slice(start, end));
const first = Array.isArray(parsed) ? parsed[0] : null;
if (first && typeof first.filename === "string" && first.filename) {
process.stdout.write(first.filename);
process.exit(0);
}
} catch {
// Keep scanning; npm lifecycle output can legally precede the JSON.
}
}
console.error("Could not find npm pack --json output with a filename.");
process.exit(1);
NODE
)"
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
echo "npm pack did not produce a tarball file." >&2
exit 1
+1 -1
View File
@@ -8,7 +8,7 @@ Docs: https://docs.openclaw.ai
### Fixes
## 2026.4.9-beta.1
## 2026.4.9
### Changes
+1 -1
View File
@@ -66,7 +66,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 2026040901
versionName = "2026.4.9-beta.1"
versionName = "2026.4.9"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.9-beta.1</string>
<string>2026.4.9</string>
<key>CFBundleVersion</key>
<string>2026040901</string>
<key>CFBundleIconFile</key>
@@ -3,6 +3,7 @@ import * as commandRegistryModule from "openclaw/plugin-sdk/command-auth";
import type { ChatCommandDefinition, CommandArgsParsing } from "openclaw/plugin-sdk/command-auth";
import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import * as pluginRuntimeModule from "openclaw/plugin-sdk/plugin-runtime";
import * as dispatcherModule from "openclaw/plugin-sdk/reply-dispatch-runtime";
import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
import * as commandTextModule from "openclaw/plugin-sdk/text-runtime";
@@ -10,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
import * as nativeCommandRouteModule from "./native-command-route.js";
import { replyWithDiscordModelPickerProviders } from "./native-command-ui.js";
import {
__testing as nativeCommandTesting,
@@ -256,9 +258,14 @@ describe("Discord model picker interactions", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
nativeCommandTesting.setMatchPluginCommand(pluginRuntimeModule.matchPluginCommand);
nativeCommandTesting.setExecutePluginCommand(pluginRuntimeModule.executePluginCommand);
nativeCommandTesting.setDispatchReplyWithDispatcher(
dispatcherModule.dispatchReplyWithDispatcher,
);
nativeCommandTesting.setResolveDiscordNativeInteractionRouteState(
nativeCommandRouteModule.resolveDiscordNativeInteractionRouteState,
);
});
afterEach(() => {
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import { findPreferredDmConversationByUserId } from "./conversation-store-helpers.js";
import type { MSTeamsConversationStoreEntry } from "./conversation-store.js";
function entry(params: {
conversationId: string;
userId?: string;
aadObjectId?: string;
conversationType?: string;
lastSeenAt?: string;
}): MSTeamsConversationStoreEntry {
return {
conversationId: params.conversationId,
reference: {
user: {
id: params.userId ?? "user-1",
aadObjectId: params.aadObjectId ?? "aad-1",
},
conversation: {
id: params.conversationId,
conversationType: params.conversationType,
},
lastSeenAt: params.lastSeenAt,
},
};
}
describe("findPreferredDmConversationByUserId", () => {
it("returns null for empty id", () => {
expect(findPreferredDmConversationByUserId([], " ")).toBeNull();
});
it("returns null when no entries match", () => {
const entries = [entry({ conversationId: "conv-1", aadObjectId: "other-user" })];
expect(findPreferredDmConversationByUserId(entries, "aad-1")).toBeNull();
});
it("returns a personal DM conversation by aadObjectId", () => {
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("returns a personal DM conversation by user.id", () => {
const entries = [
entry({
conversationId: "dm-conv",
userId: "user-target",
aadObjectId: "other",
conversationType: "personal",
}),
];
const result = findPreferredDmConversationByUserId(entries, "user-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("does NOT return a channel conversation for a user lookup (#54520)", () => {
// This is the core bug: user sends messages in both a DM and a channel.
// The channel conversation also carries the user's aadObjectId.
// findPreferredDmByUserId must NOT return the channel conversation.
const entries = [
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
it("does NOT return a groupChat conversation for a user lookup (#54520)", () => {
const entries = [
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
it("prefers personal DM over channel even when channel is more recent (#54520)", () => {
// Reproduces the exact race: channel message arrives after DM, but the
// DM conversation should still be returned.
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("prefers personal DM over groupChat even when groupChat is more recent", () => {
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("prefers the freshest personal DM when multiple exist", () => {
const entries = [
entry({
conversationId: "dm-old",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "dm-new",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-new");
});
it("falls back to unknown-type entries when no personal conversations exist", () => {
// Legacy entries without conversationType should still be usable as a
// fallback to avoid breaking existing deployments.
const entries = [
entry({
conversationId: "legacy-conv",
aadObjectId: "aad-target",
// No conversationType set (legacy entry)
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("legacy-conv");
});
it("prefers personal over unknown-type entries", () => {
const entries = [
entry({
conversationId: "legacy-conv",
aadObjectId: "aad-target",
lastSeenAt: "2026-03-25T21:00:00.000Z",
// No conversationType
}),
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("does NOT fall back to channel/group when no personal or unknown entries exist", () => {
const entries = [
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
});
@@ -51,30 +51,45 @@ export function findPreferredDmConversationByUserId(
return null;
}
const matches: MSTeamsConversationStoreEntry[] = [];
// Partition user matches into DM-safe and non-DM buckets.
// Channel and group conversations also carry the sender's aadObjectId, but
// returning one of those when the caller asked for a user-targeted DM would
// leak the reply into a shared channel -- the root cause of #54520.
const personalMatches: MSTeamsConversationStoreEntry[] = [];
const unknownTypeMatches: MSTeamsConversationStoreEntry[] = [];
for (const entry of entries) {
if (entry.reference.user?.aadObjectId === target || entry.reference.user?.id === target) {
matches.push(entry);
if (entry.reference.user?.aadObjectId !== target && entry.reference.user?.id !== target) {
continue;
}
const convType = normalizeLowercaseStringOrEmpty(
entry.reference.conversation?.conversationType ?? "",
);
if (convType === "personal") {
personalMatches.push(entry);
} else if (convType === "channel" || convType === "groupchat") {
// Explicitly skip channel/group conversations -- these must never be
// returned for a user-targeted DM lookup.
} else {
// Legacy entries without conversationType are ambiguous. Include them
// as a fallback but rank below confirmed personal conversations.
unknownTypeMatches.push(entry);
}
}
if (matches.length === 0) {
// Prefer confirmed personal DMs, fall back to unknown-type entries.
const candidates = personalMatches.length > 0 ? personalMatches : unknownTypeMatches;
if (candidates.length === 0) {
return null;
}
matches.sort((a, b) => {
const aType = normalizeLowercaseStringOrEmpty(a.reference.conversation?.conversationType ?? "");
const bType = normalizeLowercaseStringOrEmpty(b.reference.conversation?.conversationType ?? "");
const aPersonal = aType === "personal" ? 1 : 0;
const bPersonal = bType === "personal" ? 1 : 0;
if (aPersonal !== bPersonal) {
return bPersonal - aPersonal;
}
return (
(parseStoredConversationTimestamp(b.reference.lastSeenAt) ?? 0) -
(parseStoredConversationTimestamp(a.reference.lastSeenAt) ?? 0)
// When multiple candidates exist, prefer the most recently seen one.
if (candidates.length > 1) {
candidates.sort(
(a, b) =>
(parseStoredConversationTimestamp(b.reference.lastSeenAt) ?? 0) -
(parseStoredConversationTimestamp(a.reference.lastSeenAt) ?? 0),
);
});
}
return matches[0] ?? null;
return candidates[0] ?? null;
}
@@ -36,6 +36,15 @@ export type StoredConversationReference = {
graphChatId?: string;
/** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
timezone?: string;
/**
* Thread root message ID for channel thread messages.
* When a message arrives inside a Teams channel thread, the Bot Framework
* sets `conversation.id` to `19:xxx@thread.tacv2;messageid=<rootId>` and/or
* `replyToId` to the thread root activity ID. This field caches that root ID
* so outbound replies can target the correct thread instead of landing as
* top-level channel posts.
*/
threadId?: string;
};
export type MSTeamsConversationStoreEntry = {
+141
View File
@@ -474,6 +474,147 @@ describe("msteams messenger", () => {
expect(reference.activityId).toBeUndefined();
});
it("uses threadId instead of activityId for channel revoke fallback (#58030)", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "current-message-id",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
// threadId is the thread root, which differs from activityId (current message)
threadId: "thread-root-msg-id",
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
context: ctx,
messages: [{ text: "hello" }],
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string }; activityId?: string };
// Should use threadId (thread root), NOT activityId (current message)
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id");
expect(ref.activityId).toBeUndefined();
});
it("falls back to activityId when threadId is not set (backward compat)", async () => {
const proactiveSent: string[] = [];
let capturedReference: unknown;
const channelRef: StoredConversationReference = {
activityId: "legacy-activity-id",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
// No threadId — older stored references may not have it
};
const ctx = createRevokedThreadContext();
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: channelRef,
context: ctx,
messages: [{ text: "hello" }],
});
expect(proactiveSent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string } };
// Falls back to activityId when threadId is missing
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id");
});
it("does not add thread suffix for top-level replyStyle even with threadId set", async () => {
let capturedReference: unknown;
const sent: string[] = [];
const channelRef: StoredConversationReference = {
activityId: "current-msg",
user: { id: "user123", name: "User" },
agent: { id: "bot123", name: "Bot" },
conversation: {
id: "19:abc@thread.tacv2",
conversationType: "channel",
},
channelId: "msteams",
serviceUrl: "https://service.example.com",
threadId: "thread-root-msg-id",
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
capturedReference = reference;
await logic({
sendActivity: createRecordedSendActivity(sent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
await sendMSTeamsMessages({
replyStyle: "top-level",
adapter,
appId: "app123",
conversationRef: channelRef,
messages: [{ text: "hello" }],
});
expect(sent).toEqual(["hello"]);
const ref = capturedReference as { conversation?: { id?: string } };
// Top-level sends should NOT include thread suffix
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
});
it("retries top-level sends on transient (5xx)", async () => {
const attempts: string[] = [];
+6 -2
View File
@@ -521,12 +521,16 @@ export async function sendMSTeamsMessages(params: {
return messageIds;
};
// Resolve the thread root message ID for channel thread routing.
// `threadId` is the canonical thread root (set on inbound for channel threads);
// fall back to `activityId` for backward compatibility with older stored refs.
const resolvedThreadId = params.conversationRef.threadId ?? params.conversationRef.activityId;
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
const threadActivityId = params.conversationRef.activityId;
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const result = await withRevokedProxyFallback({
@@ -541,7 +545,7 @@ export async function sendMSTeamsMessages(params: {
const remaining = messages.slice(idx);
return {
ids:
remaining.length > 0 ? await sendProactively(remaining, idx, threadActivityId) : [],
remaining.length > 0 ? await sendProactively(remaining, idx, resolvedThreadId) : [],
fellBack: true,
};
},
+20 -2
View File
@@ -1,10 +1,11 @@
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { type OpenClawConfig, type RuntimeEnv } from "../runtime-api.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
import { normalizeMSTeamsConversationId } from "./inbound.js";
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { resolveMSTeamsSenderAccess } from "./monitor-handler/access.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
@@ -257,7 +258,8 @@ async function handleFeedbackInvoke(
}
// Strip ;messageid=... suffix to match the normalized ID used by the message handler.
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "unknown");
const rawConversationId = activity.conversation?.id ?? "unknown";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
const isNegative = reaction === "dislike";
@@ -278,6 +280,22 @@ async function handleFeedbackInvoke(
},
});
// Match the thread-aware session key used by the message handler so feedback
// events land in the correct per-thread transcript. For channel threads, the
// thread root ID comes from the ;messageid= suffix on the conversation ID or
// from activity.replyToId.
const feedbackThreadId = isChannel
? (extractMSTeamsConversationMessageId(rawConversationId) ?? activity.replyToId ?? undefined)
: undefined;
if (feedbackThreadId) {
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: feedbackThreadId,
parentSessionKey: route.sessionKey,
});
route.sessionKey = threadKeys.sessionKey;
}
// Log feedback event to session JSONL
const feedbackEvent = buildFeedbackEvent({
messageId,
@@ -0,0 +1,262 @@
import { 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 { createMSTeamsMessageHandler } from "./message-handler.js";
const runtimeApiMockState = vi.hoisted(() => ({
dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({
queuedFinal: false,
counts: {},
capturedCtxPayload: params.ctxPayload,
})),
}));
vi.mock("../../runtime-api.js", async () => {
const actual =
await vi.importActual<typeof import("../../runtime-api.js")>("../../runtime-api.js");
return {
...actual,
dispatchReplyFromConfigWithSettledDispatcher:
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher,
};
});
vi.mock("../graph-thread.js", async () => {
const actual = await vi.importActual<typeof import("../graph-thread.js")>("../graph-thread.js");
return {
...actual,
resolveTeamGroupId: vi.fn(async () => "group-1"),
fetchChannelMessage: vi.fn(async () => undefined),
fetchThreadReplies: vi.fn(async () => []),
};
});
vi.mock("../reply-dispatcher.js", () => ({
createMSTeamsReplyDispatcher: () => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
}),
}));
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: {
upsert: vi.fn(async () => undefined),
} as unknown as 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,
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 handler = createMSTeamsMessageHandler(deps);
// Thread reply: has replyToId pointing to the thread root
await handler({
activity: buildActivity({ replyToId: "thread-root-123" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey;
expect(sessionKey).toContain("thread:");
expect(sessionKey).toContain("thread-root-123");
});
it("does not append thread suffix for top-level channel messages", async () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
// Top-level channel message: no replyToId
await handler({
activity: buildActivity({ replyToId: undefined }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey;
expect(sessionKey).not.toContain("thread:");
expect(sessionKey).toBe(`agent:main:msteams:channel:${channelConversationId}`);
});
it("produces different session keys for different threads in the same channel", async () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: buildActivity({ 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" }),
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
expect(recordInboundSession).toHaveBeenCalledTimes(2);
const sessionKeyA = recordInboundSession.mock.calls[0]?.[0]?.sessionKey;
const sessionKeyB = recordInboundSession.mock.calls[1]?.[0]?.sessionKey;
expect(sessionKeyA).not.toBe(sessionKeyB);
expect(sessionKeyA).toContain("thread-a"); // normalized lowercase
expect(sessionKeyB).toContain("thread-b");
});
it("does not affect DM session keys", async () => {
const cfg: OpenClawConfig = {
channels: { msteams: { allowFrom: ["*"] } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: {
...buildActivity(),
conversation: {
id: "a:dm-conversation",
conversationType: "personal",
},
channelData: {},
replyToId: "some-reply-id",
entities: [],
},
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey;
expect(sessionKey).not.toContain("thread:");
});
it("does not affect group chat session keys", async () => {
const cfg: OpenClawConfig = {
channels: { msteams: { groupPolicy: "open" } },
} as OpenClawConfig;
const { deps, recordInboundSession } = createDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
await handler({
activity: {
...buildActivity(),
conversation: {
id: "19:group-chat-id@unq.gbl.spaces",
conversationType: "groupChat",
},
channelData: {},
replyToId: "some-reply-id",
entities: [{ type: "mention", mentioned: { id: "bot-id" } }],
},
sendActivity: vi.fn(async () => undefined),
} as unknown as Parameters<typeof handler>[0]);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
const sessionKey = recordInboundSession.mock.calls[0]?.[0]?.sessionKey;
expect(sessionKey).not.toContain("thread:");
});
});
@@ -1,4 +1,5 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
@@ -92,8 +93,10 @@ function buildStoredConversationReference(params: {
conversationId: string;
conversationType: string;
teamId?: string;
/** Thread root message ID for channel thread messages. */
threadId?: string;
}): StoredConversationReference {
const { activity, conversationId, conversationType, teamId } = params;
const { activity, conversationId, conversationType, teamId, threadId } = params;
const from = activity.from;
const conversation = activity.conversation;
const agent = activity.recipient;
@@ -115,6 +118,7 @@ function buildStoredConversationReference(params: {
serviceUrl: activity.serviceUrl,
locale: activity.locale,
...(clientInfo?.timezone ? { timezone: clientInfo.timezone } : {}),
...(threadId ? { threadId } : {}),
};
}
@@ -209,11 +213,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const teamId = activity.channelData?.team?.id;
// For channel thread messages, resolve the thread root message ID so outbound
// replies land in the correct thread. The root ID comes from the `messageid=`
// portion of conversation.id (preferred) or from activity.replyToId.
const threadId =
conversationType === "channel"
? (conversationMessageId ?? activity.replyToId ?? undefined)
: undefined;
const conversationRef = buildStoredConversationReference({
activity,
conversationId,
conversationType,
teamId,
threadId,
});
const {
@@ -442,6 +454,21 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
},
});
// Isolate channel thread sessions: each thread gets its own session key so
// context does not bleed across threads. Prefer conversationMessageId (the
// ;messageid= portion of conversation.id, i.e. the thread root) over
// activity.replyToId (which may point to a non-root parent in deep threads).
// DMs and group chats are unaffected — only channel thread replies fork.
const channelThreadId = isChannel
? (conversationMessageId ?? activity.replyToId ?? undefined)
: undefined;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: channelThreadId,
parentSessionKey: channelThreadId ? route.sessionKey : undefined,
});
route.sessionKey = threadKeys.sessionKey;
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Teams DM from ${senderName}`
+15
View File
@@ -130,6 +130,21 @@ export async function resolveMSTeamsSendContext(params: {
}
const { conversationId, ref } = found;
// Safety check: when the caller targeted a specific user (DM), verify the
// resolved conversation is actually a personal DM. Without this guard a
// stale or mismatched conversation store could route a private DM reply
// into a shared channel or group chat -- see #54520.
if (recipient.type === "user") {
const resolvedType = normalizeLowercaseStringOrEmpty(ref.conversation?.conversationType ?? "");
if (resolvedType && resolvedType !== "personal") {
throw new Error(
`Conversation reference for user:${recipient.id} resolved to a ${resolvedType} ` +
`conversation (${conversationId}) instead of a personal DM. ` +
`The bot must receive a DM from this user before it can send proactively.`,
);
}
}
const core = getMSTeamsRuntime();
const log = core.logging.getChildLogger({ name: "msteams:send" });
@@ -1,117 +1,61 @@
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
flush,
getSlackClient,
getSlackHandlerOrThrow,
getSlackTestState,
resetSlackTestState,
startSlackMonitor,
stopSlackMonitor,
} from "./monitor.test-helpers.js";
import { describe, expect, it, vi } from "vitest";
import { createSlackThreadTsResolver } from "./monitor/thread-resolution.js";
import type { SlackMessageEvent } from "./types.js";
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
const slackTestState = getSlackTestState();
type SlackConversationsClient = {
history: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
};
function makeThreadReplyEvent() {
function makeThreadReplyMessage(): SlackMessageEvent {
return {
event: {
type: "message",
user: "U1",
text: "hello",
ts: "456",
parent_user_id: "U2",
channel: "C1",
channel_type: "channel",
},
type: "message",
user: "U1",
text: "hello",
ts: "456",
parent_user_id: "U2",
channel: "C1",
channel_type: "channel",
};
}
function getConversationsClient(): SlackConversationsClient {
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
return client.conversations as SlackConversationsClient;
}
async function runMissingThreadScenario(params: {
historyResponse?: { messages: Array<{ ts?: string; thread_ts?: string }> };
historyError?: Error;
}) {
slackTestState.replyMock.mockResolvedValue({ text: "thread reply" });
const conversations = getConversationsClient();
const history = vi.fn();
if (params.historyError) {
conversations.history.mockRejectedValueOnce(params.historyError);
history.mockRejectedValueOnce(params.historyError);
} else {
conversations.history.mockResolvedValueOnce(
params.historyResponse ?? { messages: [{ ts: "456" }] },
);
history.mockResolvedValueOnce(params.historyResponse ?? { messages: [{ ts: "456" }] });
}
const { controller, run } = startSlackMonitor(monitorSlackProvider);
const handler = await getSlackHandlerOrThrow("message");
await handler(makeThreadReplyEvent());
const resolver = createSlackThreadTsResolver({
client: { conversations: { history } } as never,
cacheTtlMs: 60_000,
maxSize: 5,
});
await flush();
await stopSlackMonitor({ controller, run });
expect(slackTestState.sendMock).toHaveBeenCalledTimes(1);
return slackTestState.sendMock.mock.calls[0]?.[2];
return await resolver.resolve({
message: makeThreadReplyMessage(),
source: "message",
});
}
beforeEach(() => {
resetInboundDedupe();
});
beforeAll(async () => {
({ monitorSlackProvider } = await import("./monitor.js"));
});
beforeEach(() => {
resetInboundDedupe();
resetSlackTestState({
messages: { responsePrefix: "PFX" },
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
channels: { C1: { allow: true, requireMention: false } },
},
},
});
const conversations = getConversationsClient();
conversations.info.mockResolvedValue({
channel: { name: "general", is_channel: true },
});
});
describe("monitorSlackProvider threading", () => {
describe("Slack missing thread_ts recovery", () => {
it("recovers missing thread_ts when parent_user_id is present", async () => {
const options = await runMissingThreadScenario({
const message = await runMissingThreadScenario({
historyResponse: { messages: [{ ts: "456", thread_ts: "111.222" }] },
});
expect(options).toMatchObject({ threadTs: "111.222" });
expect(message).toMatchObject({ thread_ts: "111.222" });
});
it("continues without thread_ts when history lookup returns no thread result", async () => {
const options = await runMissingThreadScenario({
const message = await runMissingThreadScenario({
historyResponse: { messages: [{ ts: "456" }] },
});
expect(options).not.toMatchObject({ threadTs: "111.222" });
expect(message.thread_ts).toBeUndefined();
});
it("continues without thread_ts when history lookup throws", async () => {
const options = await runMissingThreadScenario({
const message = await runMissingThreadScenario({
historyError: new Error("history failed"),
});
expect(options).not.toMatchObject({ threadTs: "111.222" });
expect(message.thread_ts).toBeUndefined();
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.9-beta.1",
"version": "2026.4.9",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
+10 -4
View File
@@ -7,6 +7,7 @@ import {
createVitestRunSpecs,
parseTestProjectsArgs,
resolveChangedTargetArgs,
shouldUseLocalFullSuiteParallelByDefault,
writeVitestIncludeFile,
} from "./test-projects.test-support.mjs";
import {
@@ -125,14 +126,19 @@ function resolveParallelFullSuiteConcurrency(specCount, env) {
if (override !== null) {
return Math.min(override, specCount);
}
if (env.OPENCLAW_TEST_PROJECTS_SERIAL === "1") {
return 1;
}
if (env.CI === "true" || env.GITHUB_ACTIONS === "true") {
return 1;
}
if (
env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS !== "1" ||
env.CI === "true" ||
env.GITHUB_ACTIONS === "true"
env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS !== "1" &&
!shouldUseLocalFullSuiteParallelByDefault(env)
) {
return 1;
}
return Math.min(5, specCount);
return 1;
}
function orderFullSuiteSpecsForParallelRun(specs) {
+8 -1
View File
@@ -623,7 +623,8 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
const parallelShardCount = Number.parseInt(process.env.OPENCLAW_TEST_PROJECTS_PARALLEL ?? "", 10);
const expandToProjectConfigs =
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS === "1" ||
(Number.isFinite(parallelShardCount) && parallelShardCount > 1);
(Number.isFinite(parallelShardCount) && parallelShardCount > 1) ||
shouldUseLocalFullSuiteParallelByDefault(process.env);
return fullSuiteVitestShards.flatMap((shard) => {
if (
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD === "1" &&
@@ -642,6 +643,12 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
});
}
export function shouldUseLocalFullSuiteParallelByDefault(env = process.env) {
return (
env.OPENCLAW_TEST_PROJECTS_SERIAL !== "1" && env.CI !== "true" && env.GITHUB_ACTIONS !== "true"
);
}
export function createVitestRunSpecs(args, params = {}) {
const cwd = params.cwd ?? process.cwd();
const plans = buildVitestRunPlans(args, cwd);
@@ -53,9 +53,11 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
diagnostics: [],
})),
);
const resolveManifestContractOwnerPluginId = vi.hoisted(() => vi.fn<() => undefined>());
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
resolveManifestContractOwnerPluginId,
}));
describe("provider auth aliases", () => {
+3
View File
@@ -105,6 +105,9 @@ function expectNodePairApproveScopes(scopes: string[]): void {
describe("createNodesTool screen_record duration guardrails", () => {
beforeAll(async () => {
// The agents lane runs on the shared non-isolated runner, so clear any
// cached prior import before wiring this file's gateway/media mocks.
vi.resetModules();
({ createNodesTool } = await import("./nodes-tool.js"));
});
+1 -1
View File
@@ -83,7 +83,7 @@ describe("status-all format", () => {
},
channelLabel: "stable (config)",
gitLabel: "main · tag v1.2.3",
updateLine: "git main · ↔ origin/main · behind 2 · npm latest 2026.4.9",
updateLine: "git main · ↔ origin/main · behind 2 · npm update 2026.4.9",
updateAvailable: true,
});
});
+1 -1
View File
@@ -26910,6 +26910,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.9-beta.1",
version: "2026.4.9",
generatedAt: "2026-03-22T21:17:33.302Z",
};
+57
View File
@@ -215,6 +215,63 @@ describe("discoverUnconfiguredPlugins", () => {
});
describe("setupPluginConfig", () => {
it("allows skipping plugin setup from the multiselect prompt", async () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
...makeManifestPlugin("device-pairing", {
enabled: { label: "Enable pairing" },
}),
enabledByDefault: true,
},
],
});
const note = vi.fn(async () => {});
const select = vi.fn(async () => {
throw new Error("select should not run when plugin setup is skipped");
});
const text = vi.fn(async () => {
throw new Error("text should not run when plugin setup is skipped");
});
const confirm = vi.fn(async () => {
throw new Error("confirm should not run when plugin setup is skipped");
});
const result = await setupPluginConfig({
config: {
plugins: {
entries: {
"device-pairing": {
enabled: true,
},
},
},
},
prompter: {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note,
select: select as unknown as WizardPrompter["select"],
multiselect: vi.fn(async () => ["__skip__"]) as unknown as WizardPrompter["multiselect"],
text,
confirm,
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
},
});
expect(result).toEqual({
plugins: {
entries: {
"device-pairing": {
enabled: true,
},
},
},
});
expect(note).not.toHaveBeenCalled();
});
it("writes dotted uiHint values into nested plugin config", async () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [
+13 -6
View File
@@ -311,15 +311,22 @@ export async function setupPluginConfig(params: {
const selected = await params.prompter.multiselect({
message: "Configure plugins (select to set up now, or skip)",
options: unconfigured.map((p) => ({
value: p.id,
label: p.name,
hint: `${Object.keys(p.uiHints).length} field${Object.keys(p.uiHints).length === 1 ? "" : "s"}`,
})),
options: [
{
value: "__skip__",
label: "Skip for now",
hint: "Continue without configuring plugins",
},
...unconfigured.map((p) => ({
value: p.id,
label: p.name,
hint: `${Object.keys(p.uiHints).length} field${Object.keys(p.uiHints).length === 1 ? "" : "s"}`,
})),
],
});
let config = params.config;
for (const pluginId of selected) {
for (const pluginId of selected.filter((value) => value !== "__skip__")) {
const plugin = unconfigured.find((p) => p.id === pluginId);
if (!plugin) {
continue;
+61
View File
@@ -196,9 +196,11 @@ describe("scripts/test-projects changed-target routing", () => {
describe("scripts/test-projects full-suite sharding", () => {
it("splits untargeted runs into fixed core shards and per-extension configs", () => {
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
try {
expect(buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config)).toEqual([
"vitest.full-core-unit-fast.config.ts",
@@ -239,13 +241,67 @@ describe("scripts/test-projects full-suite sharding", () => {
} else {
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
}
if (previousSerial === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
}
});
it("expands untargeted local runs to leaf project configs by default", () => {
const previousLeafShards = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
const previousCi = process.env.CI;
const previousActions = process.env.GITHUB_ACTIONS;
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
delete process.env.CI;
delete process.env.GITHUB_ACTIONS;
try {
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
expect(configs).toContain("vitest.gateway.config.ts");
expect(configs).toContain("vitest.extension-telegram.config.ts");
expect(configs).not.toContain("vitest.full-agentic.config.ts");
expect(configs).not.toContain("vitest.full-core-unit-fast.config.ts");
} finally {
if (previousLeafShards === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
} else {
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previousLeafShards;
}
if (previousParallel === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
}
if (previousSerial === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
if (previousCi === undefined) {
delete process.env.CI;
} else {
process.env.CI = previousCi;
}
if (previousActions === undefined) {
delete process.env.GITHUB_ACTIONS;
} else {
process.env.GITHUB_ACTIONS = previousActions;
}
}
});
it("can skip the aggregate extension shard when CI runs dedicated extension shards", () => {
const previous = process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD;
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = "1";
process.env.OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD = "1";
try {
const configs = buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
@@ -263,6 +319,11 @@ describe("scripts/test-projects full-suite sharding", () => {
} else {
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
}
if (previousSerial === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_SERIAL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_SERIAL = previousSerial;
}
}
});