Skip to content

Commit 8b57b03

Browse files
authored
test(windows): remove unused launcher assertion local
1 parent 67364da commit 8b57b03

18 files changed

Lines changed: 705 additions & 30 deletions

docs/channels/mattermost.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ Notes:
155155

156156
- `onchar` still responds to explicit @mentions.
157157
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
158+
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
158159

159160
## Threading and sessions
160161

extensions/mattermost/src/mattermost/monitor-gating.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,84 @@ describe("mattermost monitor gating", () => {
5959
});
6060
});
6161

62+
it("processes engaged thread follow-ups without a mention", () => {
63+
const resolveRequireMention = vi.fn(() => true);
64+
65+
expect(
66+
evaluateMattermostMentionGate({
67+
kind: "channel",
68+
cfg: {} as never,
69+
accountId: "default",
70+
channelId: "chan-1",
71+
resolveRequireMention,
72+
wasMentioned: false,
73+
threadAlreadyEngaged: true,
74+
isControlCommand: false,
75+
commandAuthorized: false,
76+
oncharEnabled: false,
77+
oncharTriggered: false,
78+
canDetectMention: true,
79+
}),
80+
).toEqual({
81+
shouldRequireMention: true,
82+
shouldBypassMention: false,
83+
effectiveWasMentioned: true,
84+
dropReason: null,
85+
});
86+
});
87+
88+
it("engaged threads respond even when onchar is enabled but not triggered", () => {
89+
const resolveRequireMention = vi.fn(() => true);
90+
91+
expect(
92+
evaluateMattermostMentionGate({
93+
kind: "channel",
94+
cfg: {} as never,
95+
accountId: "default",
96+
channelId: "chan-1",
97+
resolveRequireMention,
98+
wasMentioned: false,
99+
threadAlreadyEngaged: true,
100+
isControlCommand: false,
101+
commandAuthorized: false,
102+
oncharEnabled: true,
103+
oncharTriggered: false,
104+
canDetectMention: true,
105+
}),
106+
).toEqual({
107+
shouldRequireMention: true,
108+
shouldBypassMention: false,
109+
effectiveWasMentioned: true,
110+
dropReason: null,
111+
});
112+
});
113+
114+
it("drops non-mentioned channel traffic outside an engaged thread", () => {
115+
const resolveRequireMention = vi.fn(() => true);
116+
117+
expect(
118+
evaluateMattermostMentionGate({
119+
kind: "channel",
120+
cfg: {} as never,
121+
accountId: "default",
122+
channelId: "chan-1",
123+
resolveRequireMention,
124+
wasMentioned: false,
125+
threadAlreadyEngaged: false,
126+
isControlCommand: false,
127+
commandAuthorized: false,
128+
oncharEnabled: false,
129+
oncharTriggered: false,
130+
canDetectMention: true,
131+
}),
132+
).toEqual({
133+
shouldRequireMention: true,
134+
shouldBypassMention: false,
135+
effectiveWasMentioned: false,
136+
dropReason: "missing-mention",
137+
});
138+
});
139+
62140
it("bypasses mention for authorized control commands and allows direct chats", () => {
63141
const resolveRequireMention = vi.fn(() => true);
64142

extensions/mattermost/src/mattermost/monitor-gating.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export type MattermostMentionGateInput = {
4343
requireMentionOverride?: boolean;
4444
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
4545
wasMentioned: boolean;
46+
// Bot has already replied in this thread; treat follow-ups as addressed so the
47+
// user need not re-mention on every turn (parity with Slack thread participation).
48+
threadAlreadyEngaged?: boolean;
4649
isControlCommand: boolean;
4750
commandAuthorized: boolean;
4851
oncharEnabled: boolean;
@@ -75,12 +78,16 @@ export function evaluateMattermostMentionGate(
7578
!params.wasMentioned &&
7679
params.commandAuthorized;
7780
const effectiveWasMentioned =
78-
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
81+
params.wasMentioned ||
82+
shouldBypassMention ||
83+
params.oncharTriggered ||
84+
params.threadAlreadyEngaged === true;
7985
if (
8086
params.oncharEnabled &&
8187
!params.oncharTriggered &&
8288
!params.wasMentioned &&
83-
!params.isControlCommand
89+
!params.isControlCommand &&
90+
params.threadAlreadyEngaged !== true
8491
) {
8592
return {
8693
shouldRequireMention,

extensions/mattermost/src/mattermost/monitor.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
371371
it("suppresses reasoning-prefixed finals before preview finalization", async () => {
372372
const draftStream = createDraftStreamMock();
373373
const deliverFinal = vi.fn(async () => {});
374+
const recordThreadParticipation = vi.fn();
374375

375376
await deliverMattermostReplyWithDraftPreview({
376377
payload: { text: " \n > Reasoning:\n> _hidden_" } as never,
@@ -382,6 +383,7 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
382383
resolvePreviewFinalText: (text) => text?.trim(),
383384
previewState: { finalizedViaPreviewPost: false },
384385
logVerboseMessage: vi.fn(),
386+
recordThreadParticipation,
385387
deliverPayload: deliverFinal,
386388
});
387389

@@ -390,6 +392,36 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
390392
expect(draftStream.discardPending).not.toHaveBeenCalled();
391393
expect(draftStream.clear).not.toHaveBeenCalled();
392394
expect(updateMattermostPostSpy).not.toHaveBeenCalled();
395+
// No visible reply was sent, so the thread must not be marked as participated.
396+
expect(recordThreadParticipation).not.toHaveBeenCalled();
397+
});
398+
399+
it("records thread participation when a same-thread final finalizes the preview in place", async () => {
400+
const draftStream = createDraftStreamMock();
401+
const deliverFinal = vi.fn(async () => {});
402+
const recordThreadParticipation = vi.fn();
403+
404+
await deliverMattermostReplyWithDraftPreview({
405+
payload: { text: "All good" } as never,
406+
info: { kind: "final" },
407+
kind: "channel",
408+
client: createMattermostClientMock(),
409+
draftStream,
410+
effectiveReplyToId: "thread-root-1",
411+
resolvePreviewFinalText: (text) => text?.trim(),
412+
previewState: { finalizedViaPreviewPost: false },
413+
logVerboseMessage: vi.fn(),
414+
recordThreadParticipation,
415+
deliverPayload: deliverFinal,
416+
});
417+
418+
// Default streaming finalizes by editing the preview post, bypassing deliverPayload —
419+
// participation must still be recorded (regression: PR #95552 review P1).
420+
expect(updateMattermostPostSpy).toHaveBeenCalledWith(expect.anything(), "preview-post-1", {
421+
message: "All good",
422+
});
423+
expect(deliverFinal).not.toHaveBeenCalled();
424+
expect(recordThreadParticipation).toHaveBeenCalledTimes(1);
393425
});
394426

395427
it("deletes the preview after a successful normal final send", async () => {

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ import {
116116
import { sendMessageMattermost } from "./send.js";
117117
import { cleanupSlashCommands } from "./slash-commands.js";
118118
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
119+
import {
120+
hasMattermostThreadParticipationWithPersistence,
121+
recordMattermostThreadParticipation,
122+
} from "./thread-participation.js";
119123

120124
export {
121125
evaluateMattermostMentionGate,
@@ -327,6 +331,10 @@ type MattermostDraftPreviewDeliverParams = {
327331
previewState: MattermostDraftPreviewState;
328332
logVerboseMessage: (message: string) => void;
329333
deliverPayload: (payload: ReplyPayload) => Promise<void>;
334+
// Visible same-thread finals can be delivered by editing the draft preview in
335+
// place (onPreviewFinalized) without ever calling deliverPayload; this lets the
336+
// caller record thread participation on that path too.
337+
recordThreadParticipation?: () => void;
330338
};
331339

332340
export async function deliverMattermostReplyWithDraftPreview(
@@ -374,6 +382,9 @@ export async function deliverMattermostReplyWithDraftPreview(
374382
},
375383
onPreviewFinalized: () => {
376384
params.previewState.finalizedViaPreviewPost = true;
385+
// The visible final reply landed by editing the preview post, so the normal
386+
// deliverPayload record path is skipped; record participation explicitly here.
387+
params.recordThreadParticipation?.();
377388
},
378389
buildSupplementalPayload: (payload) =>
379390
getReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : undefined,
@@ -1484,6 +1495,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
14841495
: { triggered: false, stripped: rawText };
14851496
const oncharTriggered = oncharResult.triggered;
14861497
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
1498+
// Threads the bot already replied in auto-engage: follow-ups resume without
1499+
// a re-mention even under requireMention. Keyed by the thread root id.
1500+
const threadAlreadyEngaged =
1501+
kind !== "direct" && effectiveReplyToId
1502+
? await hasMattermostThreadParticipationWithPersistence({
1503+
accountId: account.accountId,
1504+
channelId,
1505+
threadRootId: effectiveReplyToId,
1506+
})
1507+
: false;
14871508
const mentionDecision = evaluateMattermostMentionGate({
14881509
kind,
14891510
cfg,
@@ -1493,6 +1514,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
14931514
requireMentionOverride: account.requireMention,
14941515
resolveRequireMention: core.channel.groups.resolveRequireMention,
14951516
wasMentioned,
1517+
threadAlreadyEngaged,
14961518
isControlCommand,
14971519
commandAuthorized,
14981520
oncharEnabled,
@@ -1781,6 +1803,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
17811803
if (info.kind === "final") {
17821804
progressDraft.markFinalReplyStarted();
17831805
}
1806+
// A visible same-thread final arrives either via a normal send or by editing
1807+
// the draft preview in place; record participation on whichever path fires.
1808+
const markThreadParticipation = () => {
1809+
if (kind !== "direct" && effectiveReplyToId) {
1810+
recordMattermostThreadParticipation(
1811+
account.accountId,
1812+
channelId,
1813+
effectiveReplyToId,
1814+
{ agentId: route.agentId },
1815+
);
1816+
}
1817+
};
17841818
await deliverMattermostReplyWithDraftPreview({
17851819
payload: payloadEntry,
17861820
info,
@@ -1791,6 +1825,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
17911825
resolvePreviewFinalText,
17921826
previewState,
17931827
logVerboseMessage,
1828+
recordThreadParticipation: markThreadParticipation,
17941829
deliverPayload: async (payloadToDeliver) => {
17951830
const outcome = await deliverMattermostReplyPayload({
17961831
core,
@@ -1809,6 +1844,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
18091844
sendMessage: sendMessageMattermost,
18101845
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
18111846
});
1847+
// Record only on a visible send so threads we merely observed
1848+
// (reasoning-only/empty/suppressed) do not auto-engage later.
1849+
if (outcome === "text" || outcome === "media") {
1850+
markThreadParticipation();
1851+
}
18121852
const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({
18131853
outcome,
18141854
payload: payloadToDeliver,
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Mattermost tests cover thread participation cache plugin behavior.
2+
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
3+
import {
4+
createPluginStateKeyedStoreForTests,
5+
resetPluginStateStoreForTests,
6+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
7+
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
8+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
9+
import { setMattermostRuntime } from "../runtime.js";
10+
import {
11+
clearMattermostThreadParticipationCache,
12+
hasMattermostThreadParticipationWithPersistence,
13+
recordMattermostThreadParticipation,
14+
} from "./thread-participation.js";
15+
16+
// Drain microtasks + the immediate queue so the fire-and-forget persistent write
17+
// in recordMattermostThreadParticipation has settled before we assert on it.
18+
const flush = (): Promise<void> =>
19+
new Promise((resolve) => {
20+
setImmediate(resolve);
21+
});
22+
23+
function setRuntime(openKeyedStore: (options: OpenKeyedStoreOptions) => unknown): void {
24+
setMattermostRuntime({
25+
state: { openKeyedStore },
26+
logging: { getChildLogger: () => ({ warn() {} }) },
27+
} as unknown as PluginRuntime);
28+
}
29+
30+
function setPersistentRuntime(): void {
31+
setRuntime((options) => createPluginStateKeyedStoreForTests("mattermost", options));
32+
}
33+
34+
describe("mattermost thread participation", () => {
35+
beforeEach(() => {
36+
resetPluginStateStoreForTests();
37+
clearMattermostThreadParticipationCache();
38+
setPersistentRuntime();
39+
});
40+
41+
afterEach(() => {
42+
clearMattermostThreadParticipationCache();
43+
resetPluginStateStoreForTests();
44+
});
45+
46+
it("remembers a thread the bot replied in", async () => {
47+
recordMattermostThreadParticipation("acct", "chan", "root-1");
48+
await expect(
49+
hasMattermostThreadParticipationWithPersistence({
50+
accountId: "acct",
51+
channelId: "chan",
52+
threadRootId: "root-1",
53+
}),
54+
).resolves.toBe(true);
55+
});
56+
57+
it("isolates participation by account, channel, and thread", async () => {
58+
recordMattermostThreadParticipation("acct", "chan", "root-1");
59+
await flush();
60+
for (const probe of [
61+
{ accountId: "other", channelId: "chan", threadRootId: "root-1" },
62+
{ accountId: "acct", channelId: "other", threadRootId: "root-1" },
63+
{ accountId: "acct", channelId: "chan", threadRootId: "root-2" },
64+
]) {
65+
await expect(hasMattermostThreadParticipationWithPersistence(probe)).resolves.toBe(false);
66+
}
67+
});
68+
69+
it("ignores empty identifiers", async () => {
70+
recordMattermostThreadParticipation("", "chan", "root-1");
71+
await expect(
72+
hasMattermostThreadParticipationWithPersistence({
73+
accountId: "",
74+
channelId: "chan",
75+
threadRootId: "root-1",
76+
}),
77+
).resolves.toBe(false);
78+
});
79+
80+
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
81+
recordMattermostThreadParticipation("acct", "chan", "root-1");
82+
await flush();
83+
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
84+
clearMattermostThreadParticipationCache();
85+
await expect(
86+
hasMattermostThreadParticipationWithPersistence({
87+
accountId: "acct",
88+
channelId: "chan",
89+
threadRootId: "root-1",
90+
}),
91+
).resolves.toBe(true);
92+
});
93+
94+
it("degrades to in-memory only when the persistent store fails", async () => {
95+
setRuntime(() => {
96+
throw new Error("sqlite unavailable");
97+
});
98+
// record + read must not throw; the in-memory cache still answers.
99+
recordMattermostThreadParticipation("acct", "chan", "root-1");
100+
await expect(
101+
hasMattermostThreadParticipationWithPersistence({
102+
accountId: "acct",
103+
channelId: "chan",
104+
threadRootId: "root-1",
105+
}),
106+
).resolves.toBe(true);
107+
await expect(
108+
hasMattermostThreadParticipationWithPersistence({
109+
accountId: "acct",
110+
channelId: "chan",
111+
threadRootId: "missing",
112+
}),
113+
).resolves.toBe(false);
114+
});
115+
});

0 commit comments

Comments
 (0)