Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0512059dd4 | |||
| b5c3c15dcf | |||
| 1fed7bc379 | |||
| 9edfefedf7 | |||
| 38aa1edf76 | |||
| 62bde7ede3 | |||
| b27918007a | |||
| 74b5b97f62 | |||
| 0faae33b0c | |||
| 5b28ab83ef |
@@ -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
@@ -8,7 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.4.9-beta.1
|
||||
## 2026.4.9
|
||||
|
||||
### Changes
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user