Files
openclaw/src/auto-reply/reply/reply-plumbing.test.ts
Vincent Koc 89a1e99815 fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack

Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':

1. Typing indicator created threads via statusThreadTs fallback (#16868)
   - resolveSlackThreadTargets fell back to messageTs for statusThreadTs
   - 'is typing...' was posted as thread reply, creating a thread
   - Fix: remove messageTs fallback, let statusThreadTs be undefined

2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
   - Slack dock had allowExplicitReplyTagsWhenOff: true
   - Reply tags from system prompt always threaded regardless of config
   - Fix: set allowExplicitReplyTagsWhenOff to false for Slack

3. Contradictory replyToMode defaults in codebase (#20827)
   - monitor/provider.ts defaulted to 'all'
   - accounts.ts defaulted to 'off' (matching docs)
   - Fix: align provider.ts default to 'off' per documentation

Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827

* fix(slack): respect replyToMode in DMs even with typing indicator thread

When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.

Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.

Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).

This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity

Fixes #16868

* refactor(slack): eliminate redundant resolveSlackThreadContext call

- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach

* docs(slack): add JSDoc to resolveSlackThreadTargets

Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.

* docs(changelog): record Slack replyToMode off threading fixes

---------

Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
2026-02-22 13:27:50 -05:00

286 lines
8.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
import type { TemplateContext } from "../templating.js";
import { buildThreadingToolContext } from "./agent-runner-utils.js";
import { applyReplyThreading } from "./reply-payloads.js";
import {
formatRunLabel,
formatRunStatus,
resolveSubagentLabel,
sortSubagentRuns,
} from "./subagents-utils.js";
describe("buildThreadingToolContext", () => {
const cfg = {} as OpenClawConfig;
it("uses conversation id for WhatsApp", () => {
const sessionCtx = {
Provider: "whatsapp",
From: "123@g.us",
To: "+15550001",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("123@g.us");
});
it("falls back to To for WhatsApp when From is missing", () => {
const sessionCtx = {
Provider: "whatsapp",
To: "+15550001",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("+15550001");
});
it("uses the recipient id for other channels", () => {
const sessionCtx = {
Provider: "telegram",
From: "user:42",
To: "chat:99",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("chat:99");
});
it("normalizes signal direct targets for tool context", () => {
const sessionCtx = {
Provider: "signal",
ChatType: "direct",
From: "signal:+15550001",
To: "signal:+15550002",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("+15550001");
});
it("preserves signal group ids for tool context", () => {
const sessionCtx = {
Provider: "signal",
ChatType: "group",
To: "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
});
it("uses the sender handle for iMessage direct chats", () => {
const sessionCtx = {
Provider: "imessage",
ChatType: "direct",
From: "imessage:+15550001",
To: "chat_id:12",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("imessage:+15550001");
});
it("uses chat_id for iMessage groups", () => {
const sessionCtx = {
Provider: "imessage",
ChatType: "group",
From: "imessage:group:7",
To: "chat_id:7",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: cfg,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("chat_id:7");
});
it("prefers MessageThreadId for Slack tool threading", () => {
const sessionCtx = {
Provider: "slack",
To: "channel:C1",
MessageThreadId: "123.456",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("C1");
expect(result.currentThreadTs).toBe("123.456");
});
});
describe("applyReplyThreading auto-threading", () => {
it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => {
const result = applyReplyThreading({
payloads: [{ text: "Hello" }],
replyToMode: "first",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBe("42");
});
it("threads only first payload when mode is 'first'", () => {
const result = applyReplyThreading({
payloads: [{ text: "A" }, { text: "B" }],
replyToMode: "first",
currentMessageId: "42",
});
expect(result).toHaveLength(2);
expect(result[0].replyToId).toBe("42");
expect(result[1].replyToId).toBeUndefined();
});
it("threads all payloads when mode is 'all'", () => {
const result = applyReplyThreading({
payloads: [{ text: "A" }, { text: "B" }],
replyToMode: "all",
currentMessageId: "42",
});
expect(result).toHaveLength(2);
expect(result[0].replyToId).toBe("42");
expect(result[1].replyToId).toBe("42");
});
it("strips replyToId when mode is 'off'", () => {
const result = applyReplyThreading({
payloads: [{ text: "A" }],
replyToMode: "off",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBeUndefined();
});
it("does not bypass off mode for Slack when reply is implicit", () => {
const result = applyReplyThreading({
payloads: [{ text: "A" }],
replyToMode: "off",
replyToChannel: "slack",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBeUndefined();
});
it("strips explicit tags for Slack when off mode disallows tags", () => {
const result = applyReplyThreading({
payloads: [{ text: "[[reply_to_current]]A" }],
replyToMode: "off",
replyToChannel: "slack",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBeUndefined();
});
it("keeps explicit tags for Telegram when off mode is enabled", () => {
const result = applyReplyThreading({
payloads: [{ text: "[[reply_to_current]]A" }],
replyToMode: "off",
replyToChannel: "telegram",
currentMessageId: "42",
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBe("42");
expect(result[0].replyToTag).toBe(true);
});
});
const baseRun: SubagentRunRecord = {
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
};
describe("subagents utils", () => {
it("resolves labels from label, task, or fallback", () => {
expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label");
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task");
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe(
"fallback",
);
});
it("formats run labels with truncation", () => {
const long = "x".repeat(100);
const run = { ...baseRun, label: long };
const formatted = formatRunLabel(run, { maxLength: 10 });
expect(formatted.startsWith("x".repeat(10))).toBe(true);
expect(formatted.endsWith("…")).toBe(true);
});
it("sorts subagent runs by newest start/created time", () => {
const runs: SubagentRunRecord[] = [
{ ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 },
{ ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 },
{ ...baseRun, runId: "run-3", createdAt: 900 },
];
const sorted = sortSubagentRuns(runs);
expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]);
});
it("formats run status from outcome and timestamps", () => {
expect(formatRunStatus({ ...baseRun })).toBe("running");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
"timeout",
);
});
it("formats duration compact for seconds and minutes", () => {
expect(formatDurationCompact(45_000)).toBe("45s");
expect(formatDurationCompact(65_000)).toBe("1m5s");
});
});