fix: centralize source reply delivery mode

This commit is contained in:
Peter Steinberger
2026-04-28 09:13:49 +01:00
parent 1257e0e4ae
commit 67b16a4a6d
21 changed files with 568 additions and 103 deletions

View File

@@ -293,9 +293,15 @@ checks that need parity or remote state.
5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
7. If you need artifacts (coverage reports, build outputs, etc.), download them:
7. If a narrow PR reports a full sync or the box was reused/expired, sanity
check the remote copy before a slow gate:
`blacksmith testbox run --id <ID> "pnpm testbox:sanity"`.
If it reports missing root files or mass tracked deletions, stop the box and
warm a fresh one. Use `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` only for an
intentional large deletion PR.
8. If you need artifacts (coverage reports, build outputs, etc.), download them:
`blacksmith testbox download --id <ID> coverage/ ./coverage/`
8. Once green, commit and push.
9. Once green, commit and push.
## OpenClaw full test suite
@@ -314,6 +320,12 @@ When validating before commit/push in maintainer Testbox mode, run
`pnpm check:changed` inside the warmed box first when appropriate, then the full
suite with the profile above if broad confidence is needed.
Run `pnpm testbox:sanity` inside the warmed box before the broad command when
the sync looks suspicious. It checks that root files such as `pnpm-lock.yaml`
still exist and fails on 200 or more tracked deletions. That catches stale or
corrupted rsync state before dependency install or Vitest failures hide the real
problem.
## Examples
blacksmith testbox warmup ci-check-testbox.yml

View File

@@ -76,6 +76,9 @@ Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
- Direct test edits run themselves. Source edits prefer explicit mappings,
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
edits are skipped by default unless they have precise mapped tests.
- Shared group-room delivery config and source-reply prompt edits are precise
mapped tests: they run the core auto-reply regressions plus Discord and Slack
delivery tests so cross-channel default changes fail before a PR push.
- Public SDK or contract edits do not automatically run every plugin test.
`check:changed` proves extension type contracts; the agent chooses the
smallest plugin/contract Vitest proof that matches the actual risk.

View File

@@ -216,6 +216,10 @@ dispatch always shards full Matrix coverage into `transport`, `media`,
runs the release-critical QA Lab lanes before release approval; its QA parity
gate runs the candidate and baseline packs as parallel lane jobs, then downloads
both artifacts into a small report job for the final parity comparison.
Do not put the PR landing path behind `Parity gate` unless the change actually
touches QA runtime, model-pack parity, or a surface the parity workflow owns.
For normal channel, config, docs, or unit-test fixes, treat it as an optional
signal and follow the scoped CI/check evidence instead.
The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for
post-land duplicate cleanup. It defaults to dry-run and only closes explicitly
@@ -330,6 +334,25 @@ The separate `install-smoke` workflow reuses the same scope script through its o
Current release Docker chunks are `core`, `package-update-openai`, `package-update-anthropic`, `package-update-core`, `plugins-runtime-plugins`, `plugins-runtime-services`, `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate `bundled-channels` chunk remains available for manual one-shot reruns, and `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` remain aggregate plugin/runtime aliases, but the release workflow uses the split chunks so channel smokes, update targets, plugin runtime checks, and bundled plugin install/uninstall sweeps can run in parallel. Targeted `docker_lanes` dispatches also split multiple selected lanes into parallel jobs after one shared package/image preparation step, and bundled-channel update lanes retry once for transient npm network failures.
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local check gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod and core test typecheck plus core lint/guards, core test-only changes run only core test typecheck plus core lint, extension production changes run extension prod and extension test typecheck plus extension lint, and extension test-only changes run extension test typecheck plus extension lint. Public Plugin SDK or plugin-contract changes expand to extension typecheck because extensions depend on those core contracts, but Vitest extension sweeps are explicit test work. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all check lanes.
Local changed-test routing lives in `scripts/test-projects.test-support.mjs` and
is intentionally cheaper than `check:changed`: direct test edits run themselves,
source edits prefer explicit mappings, then sibling tests and import-graph
dependents. Shared group-room delivery config is one of the explicit mappings:
changes to the group visible-reply config, source reply delivery mode, or the
message-tool system prompt route through the core reply tests plus Discord and
Slack delivery regressions so a shared default change fails before the first PR
push. Use `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` only when the change
is harness-wide enough that the cheap mapped set is not a trustworthy proxy.
For Testbox validation, run from the repo root and prefer a fresh warmed box for
broad proof. Before spending a slow gate on a box that was reused, expired, or
just reported an unexpectedly large sync, run `pnpm testbox:sanity` inside the
box first. The sanity check fails fast when required root files such as
`pnpm-lock.yaml` disappeared or when `git status --short` shows at least 200
tracked deletions. That usually means the remote sync state is not a trustworthy
copy of the PR. Stop that box and warm a fresh one instead of debugging the
product test failure. For intentional large deletion PRs, set
`OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` for that sanity run.
Manual CI dispatches run `checks-node-compat-node22` as release-candidate compatibility coverage. Normal pull requests and `main` pushes skip that lane and keep the matrix focused on the Node 24 test/channel lanes.

View File

@@ -386,7 +386,7 @@ releases.
| `plugin-sdk/account-helpers` | Narrow account helpers | Account list/account-action helpers |
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` |
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix, typing, and source-delivery wiring | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives and the generic builder only |
| `plugin-sdk/bundled-channel-config-schema` | Bundled config schemas | OpenClaw-maintained bundled plugins only; new plugins must define plugin-local schemas |

View File

@@ -56,7 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline` |
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder |
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |

View File

@@ -675,6 +675,7 @@ describe("processDiscordMessage ack reactions", () => {
await processDiscordMessage(ctx as any);
await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled());
expectRemoveAckCallAt(0, "👀", {
accountId: "default",
ackReaction: "👀",
@@ -861,7 +862,7 @@ describe("processDiscordMessage session routing", () => {
...createDirectMessageContextOverrides(),
})) as any,
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBeUndefined();
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
});
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {

View File

@@ -16,7 +16,10 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelReplyPipeline,
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingPreviewToolProgress,
@@ -206,11 +209,11 @@ export async function processDiscordMessage(
if (boundThreadId && typeof threadBindings.touchThread === "function") {
threadBindings.touchThread({ threadId: boundThreadId });
}
const sourceReplyDeliveryMode = isGuildMessage
? cfg.messages?.groupChat?.visibleReplies === "automatic"
? ("automatic" as const)
: ("message_tool_only" as const)
: undefined;
const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime();
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
cfg,
ctx: { ChatType: isGuildMessage ? "channel" : undefined },
});
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",
@@ -279,8 +282,6 @@ export async function processDiscordMessage(
reactionAdapter: discordAdapter,
target: `${messageChannelId}/${message.id}`,
});
const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime();
const fromLabel = isDirectMessage
? buildDirectLabel(author)
: buildGuildLabel({

View File

@@ -230,6 +230,7 @@ describe("monitorSlackProvider tool results", () => {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
groupChat: { visibleReplies: "automatic" },
removeAckAfterReply: true,
statusReactions: statusReactionsEnabled
? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } }
@@ -521,6 +522,38 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
});
it("keeps always-on channel messages private by default", async () => {
slackTestState.config = {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
},
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
requireMention: false,
},
},
};
replyMock.mockResolvedValue({ text: "quiet" });
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
channel_type: "channel",
}),
});
await flush();
expect(replyMock).toHaveBeenCalledTimes(1);
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock).not.toHaveBeenCalled();
});
it("treats control commands as mentions for group bypass", async () => {
replyMock.mockResolvedValue({ text: "ok" });
await runChannelMessageEvent("/elevated off");
@@ -584,6 +617,20 @@ describe("monitorSlackProvider tool results", () => {
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
replyMock.mockResolvedValue(undefined);
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
groupChat: { visibleReplies: "automatic" },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");

View File

@@ -146,6 +146,22 @@ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
},
onModelSelected: undefined,
}),
resolveChannelSourceReplyDeliveryMode: (params: {
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
ctx?: { ChatType?: string };
requested?: "automatic" | "message_tool_only";
}) => {
if (params.requested) {
return params.requested;
}
const chatType = params.ctx?.ChatType;
if (chatType === "group" || chatType === "channel") {
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
? "automatic"
: "message_tool_only";
}
return "automatic";
},
}));
vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({

View File

@@ -8,7 +8,10 @@ import {
type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback";
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelReplyPipeline,
resolveChannelSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
@@ -282,12 +285,18 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
message,
replyToMode: prepared.replyToMode,
});
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
cfg,
ctx: prepared.ctxPayload,
});
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const reactionMessageTs = prepared.ackReactionMessageTs;
const messageTs = message.ts ?? message.event_ts;
const incomingThreadTs = message.thread_ts;
let didSetStatus = false;
const statusReactionsEnabled =
!sourceRepliesAreToolOnly &&
Boolean(prepared.ackReactionPromise) &&
Boolean(reactionMessageTs) &&
cfg.messages?.statusReactions?.enabled !== false;
@@ -361,57 +370,59 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId })
? compileSlackInteractiveReplies(payload)
: payload,
typing: {
start: async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
});
if (typingReaction && message.ts) {
await reactSlackMessage(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
stop: async () => {
if (!didSetStatus) {
return;
}
didSetStatus = false;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
if (typingReaction && message.ts) {
await removeSlackReaction(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
onStartError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "start",
target: typingTarget,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "stop",
target: typingTarget,
error: err,
});
},
},
typing: sourceRepliesAreToolOnly
? undefined
: {
start: async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
});
if (typingReaction && message.ts) {
await reactSlackMessage(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
stop: async () => {
if (!didSetStatus) {
return;
}
didSetStatus = false;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
if (typingReaction && message.ts) {
await removeSlackReaction(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
onStartError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "start",
target: typingTarget,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "stop",
target: typingTarget,
error: err,
});
},
},
});
const slackStreaming = resolveSlackStreamingConfig({
@@ -424,15 +435,19 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
messageTs,
isThreadReply,
});
const previewStreamingEnabled = shouldEnableSlackPreviewStreaming({
mode: slackStreaming.mode,
isDirectMessage: prepared.isDirectMessage,
threadTs: streamThreadHint,
});
const streamingEnabled = isSlackStreamingEnabled({
mode: slackStreaming.mode,
nativeStreaming: slackStreaming.nativeStreaming,
});
const previewStreamingEnabled =
!sourceRepliesAreToolOnly &&
shouldEnableSlackPreviewStreaming({
mode: slackStreaming.mode,
isDirectMessage: prepared.isDirectMessage,
threadTs: streamThreadHint,
});
const streamingEnabled =
!sourceRepliesAreToolOnly &&
isSlackStreamingEnabled({
mode: slackStreaming.mode,
nativeStreaming: slackStreaming.nativeStreaming,
});
const useStreaming = shouldUseStreaming({
streamingEnabled,
threadTs: streamThreadHint,
@@ -442,11 +457,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
useStreaming,
});
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const disableBlockStreaming = resolveSlackDisableBlockStreaming({
useStreaming,
shouldUseDraftStream,
blockStreamingEnabled,
});
const disableBlockStreaming = sourceRepliesAreToolOnly
? true
: resolveSlackDisableBlockStreaming({
useStreaming,
shouldUseDraftStream,
blockStreamingEnabled,
});
let streamSession: SlackStreamSession | null = null;
let streamFailed = false;
let usedReplyThreadTs: string | undefined;
@@ -967,6 +984,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
replyOptions: {
...replyOptions,
skillFilter: prepared.channelConfig?.skills,
sourceReplyDeliveryMode,
hasRepliedRef,
disableBlockStreaming,
onModelSelected,

View File

@@ -12,6 +12,7 @@ import {
resolveEnvelopeFormatOptions,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
@@ -524,12 +525,16 @@ export async function prepareSlackMessage(params: {
return null;
}
const { rawBody, effectiveDirectMedia } = resolvedMessageContent;
const chatType = resolveSlackChatType(conversation.resolvedChannelType);
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "slack",
accountId: account.accountId,
});
const ackReactionValue = ackReaction ?? "";
const sourceRepliesAreToolOnly =
resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) ===
"message_tool_only";
const shouldAckReaction = () =>
Boolean(
@@ -547,12 +552,13 @@ export async function prepareSlackMessage(params: {
);
const ackReactionMessageTs = message.ts;
const shouldSendAckReaction = !sourceRepliesAreToolOnly && shouldAckReaction();
const statusReactionsWillHandle =
Boolean(ackReactionMessageTs) &&
cfg.messages?.statusReactions?.enabled !== false &&
shouldAckReaction();
shouldSendAckReaction;
const ackReactionPromise =
!statusReactionsWillHandle && shouldAckReaction() && ackReactionMessageTs && ackReactionValue
!statusReactionsWillHandle && shouldSendAckReaction && ackReactionMessageTs && ackReactionValue
? reactSlackMessage(message.channel, ackReactionMessageTs, ackReactionValue, {
token: ctx.botToken,
client: ctx.app.client,
@@ -571,7 +577,6 @@ export async function prepareSlackMessage(params: {
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
const senderName = await resolveSenderName();
const chatType = resolveSlackChatType(conversation.resolvedChannelType);
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Slack DM from ${senderName}`

View File

@@ -1556,6 +1556,7 @@
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch",
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/plugins/import-specifier.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"testbox:sanity": "node scripts/testbox-sync-sanity.mjs",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts",

View File

@@ -250,17 +250,31 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
["scripts/testbox-sync-sanity.mjs", ["test/scripts/testbox-sync-sanity.test.ts"]],
]);
const TOOLING_TEST_TARGETS = new Map([
["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]],
["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]],
["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]],
["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]],
["test/scripts/testbox-sync-sanity.test.ts", ["test/scripts/testbox-sync-sanity.test.ts"]],
[
"test/scripts/vitest-local-scheduling.test.ts",
["test/scripts/vitest-local-scheduling.test.ts"],
],
]);
const GROUP_VISIBLE_REPLY_TEST_TARGETS = [
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
];
const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [
"src/agents/system-prompt.test.ts",
...GROUP_VISIBLE_REPLY_TEST_TARGETS,
];
const SOURCE_TEST_TARGETS = new Map([
...PRECISE_SOURCE_TEST_TARGETS,
[
@@ -271,6 +285,11 @@ const SOURCE_TEST_TARGETS = new Map([
"extensions/telegram/src/directory-contract.test.ts",
],
],
[
"src/plugin-sdk/channel-reply-pipeline.ts",
["src/plugins/contracts/plugin-sdk-subpaths.test.ts", ...GROUP_VISIBLE_REPLY_TEST_TARGETS],
],
["src/plugin-sdk/reply-runtime.ts", ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"]],
[
"test/helpers/channels/directory-ids.ts",
[
@@ -306,10 +325,8 @@ const SOURCE_TEST_TARGETS = new Map([
"extensions/telegram/src/directory-contract.test.ts",
],
],
[
"src/auto-reply/reply/dispatch-from-config.ts",
["src/auto-reply/reply/dispatch-from-config.test.ts"],
],
["src/auto-reply/reply/dispatch-from-config.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/auto-reply/reply/source-reply-delivery-mode.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
[
"src/auto-reply/reply/effective-reply-route.ts",
[
@@ -317,6 +334,12 @@ const SOURCE_TEST_TARGETS = new Map([
"src/auto-reply/reply/dispatch-from-config.test.ts",
],
],
["src/auto-reply/reply/get-reply-run.ts", ["src/auto-reply/reply/followup-runner.test.ts"]],
["src/auto-reply/reply/groups.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/auto-reply/get-reply-options.types.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/agents/system-prompt.ts", GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS],
["src/config/types.messages.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/config/zod-schema.core.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/auto-reply/reply/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
[
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const DEFAULT_DELETION_THRESHOLD = 200;
const REQUIRED_ROOT_FILES = ["package.json", "pnpm-lock.yaml", ".gitignore"];
function parseBooleanEnv(value) {
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
}
function parsePositiveInteger(value, fallback) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseGitShortStatus(raw) {
return raw
.split(/\r?\n/u)
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const status = line.slice(0, 2);
const rawPath = line.slice(3);
return {
line,
path: rawPath.includes(" -> ") ? (rawPath.split(" -> ").at(-1) ?? rawPath) : rawPath,
status,
trackedDeletion: status.includes("D") && status !== "??",
};
});
}
export function evaluateTestboxSyncSanity({
cwd,
statusRaw,
exists = fs.existsSync,
deletionThreshold = DEFAULT_DELETION_THRESHOLD,
allowMassDeletions = false,
}) {
const missingRootFiles = REQUIRED_ROOT_FILES.filter((file) => !exists(path.join(cwd, file)));
const statusEntries = parseGitShortStatus(statusRaw);
const trackedDeletions = statusEntries.filter((entry) => entry.trackedDeletion);
const problems = [];
if (missingRootFiles.length > 0) {
problems.push(`missing required root files: ${missingRootFiles.join(", ")}`);
}
if (!allowMassDeletions && trackedDeletions.length >= deletionThreshold) {
const examples = trackedDeletions
.slice(0, 8)
.map((entry) => entry.path)
.join(", ");
problems.push(
`remote git status has ${trackedDeletions.length} tracked deletions (threshold ${deletionThreshold}); examples: ${examples}`,
);
}
return {
ok: problems.length === 0,
missingRootFiles,
problems,
statusEntryCount: statusEntries.length,
trackedDeletionCount: trackedDeletions.length,
};
}
function git(args, cwd) {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
export function runTestboxSyncSanity({
cwd = process.cwd(),
env = process.env,
stdout = process.stdout,
stderr = process.stderr,
} = {}) {
const root = git(["rev-parse", "--show-toplevel"], cwd).trim();
const statusRaw = git(["status", "--short", "--untracked-files=all"], root);
const result = evaluateTestboxSyncSanity({
cwd: root,
statusRaw,
deletionThreshold: parsePositiveInteger(
env.OPENCLAW_TESTBOX_DELETION_THRESHOLD,
DEFAULT_DELETION_THRESHOLD,
),
allowMassDeletions: parseBooleanEnv(env.OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS),
});
if (!result.ok) {
stderr.write(`Testbox sync sanity failed:\n- ${result.problems.join("\n- ")}\n`);
stderr.write("Warm a fresh box or rerun from a clean repo root before spending a gate.\n");
return 1;
}
stdout.write(
`Testbox sync sanity ok: ${result.statusEntryCount} changed entries, ${result.trackedDeletionCount} tracked deletions.\n`,
);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
process.exitCode = runTestboxSyncSanity();
}

View File

@@ -490,6 +490,7 @@ const automaticGroupReplyConfig = {
},
} as const satisfies OpenClawConfig;
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let resolveSourceReplyDeliveryMode: typeof import("./source-reply-delivery-mode.js").resolveSourceReplyDeliveryMode;
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
type DispatchReplyArgs = Parameters<
@@ -498,6 +499,7 @@ type DispatchReplyArgs = Parameters<
beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ resolveSourceReplyDeliveryMode } = await import("./source-reply-delivery-mode.js"));
await import("./dispatch-acp.js");
await import("./dispatch-acp-command-bypass.js");
await import("./dispatch-acp-tts.runtime.js");
@@ -3867,6 +3869,31 @@ describe("before_dispatch hook", () => {
});
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => {
it("resolves group source delivery from shared core config", () => {
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe(
"message_tool_only",
);
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "group" } })).toBe(
"message_tool_only",
);
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe(
"automatic",
);
expect(
resolveSourceReplyDeliveryMode({
cfg: automaticGroupReplyConfig,
ctx: { ChatType: "group" },
}),
).toBe("automatic");
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "channel" },
requested: "automatic",
}),
).toBe("automatic");
});
beforeEach(() => {
resetInboundDedupe();
sessionBindingMocks.resolveByConversation.mockReset();

View File

@@ -78,6 +78,7 @@ import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
import { resolveReplyRoutingDecision } from "./routing-policy.js";
import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
@@ -193,23 +194,6 @@ const resolveRoutedPolicyConversationType = (
return undefined;
};
function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: FinalizedMsgContext;
requested?: "automatic" | "message_tool_only";
}): "automatic" | "message_tool_only" {
if (params.requested) {
return params.requested;
}
const chatType = normalizeChatType(params.ctx.ChatType);
if (chatType === "group" || chatType === "channel") {
return params.cfg.messages?.groupChat?.visibleReplies === "automatic"
? "automatic"
: "message_tool_only";
}
return "automatic";
}
const resolveSessionStoreLookup = (
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,

View File

@@ -0,0 +1,24 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
export type SourceReplyDeliveryModeContext = {
ChatType?: string;
};
export function resolveSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
}): SourceReplyDeliveryMode {
if (params.requested) {
return params.requested;
}
const chatType = normalizeChatType(params.ctx.ChatType);
if (chatType === "group" || chatType === "channel") {
return params.cfg.messages?.groupChat?.visibleReplies === "automatic"
? "automatic"
: "message_tool_only";
}
return "automatic";
}

View File

@@ -1,3 +1,8 @@
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import {
resolveSourceReplyDeliveryMode,
type SourceReplyDeliveryModeContext,
} from "../auto-reply/reply/source-reply-delivery-mode.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import {
createReplyPrefixContext,
@@ -10,12 +15,22 @@ import {
type CreateTypingCallbacksParams,
type TypingCallbacks,
} from "../channels/typing.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ReplyPayload } from "./reply-payload.js";
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
export type { CreateTypingCallbacksParams, TypingCallbacks };
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
export type { SourceReplyDeliveryMode };
export function resolveChannelSourceReplyDeliveryMode(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
}): SourceReplyDeliveryMode {
return resolveSourceReplyDeliveryMode(params);
}
export type ChannelReplyPipeline = ReplyPrefixOptions & {
typingCallbacks?: TypingCallbacks;

View File

@@ -1316,10 +1316,12 @@ describe("plugin-sdk subpath exports", () => {
"createTypingCallbacks",
"createReplyPrefixContext",
"createReplyPrefixOptions",
"resolveChannelSourceReplyDeliveryMode",
]);
expect(typeof channelReplyPipelineSdk.createTypingCallbacks).toBe("function");
expect(typeof channelReplyPipelineSdk.createReplyPrefixContext).toBe("function");
expect(typeof channelReplyPipelineSdk.createReplyPrefixOptions).toBe("function");
expect(typeof channelReplyPipelineSdk.resolveChannelSourceReplyDeliveryMode).toBe("function");
expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length);
for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) {

View File

@@ -141,6 +141,78 @@ describe("scripts/test-projects changed-target routing", () => {
});
});
it("routes group visible reply config changes through channel delivery regressions", () => {
expect(
resolveChangedTestTargetPlan([
"src/config/types.messages.ts",
"src/config/zod-schema.core.ts",
]),
).toEqual({
mode: "targets",
targets: [
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
],
});
});
it("routes source reply prompt changes through prompt and channel delivery regressions", () => {
expect(resolveChangedTestTargetPlan(["src/agents/system-prompt.ts"])).toEqual({
mode: "targets",
targets: [
"src/agents/system-prompt.test.ts",
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
],
});
});
it("routes source reply delivery mode changes through channel delivery regressions", () => {
expect(
resolveChangedTestTargetPlan(["src/auto-reply/reply/source-reply-delivery-mode.ts"]),
).toEqual({
mode: "targets",
targets: [
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
],
});
});
it("routes channel reply pipeline SDK changes through SDK and channel delivery regressions", () => {
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/channel-reply-pipeline.ts"])).toEqual({
mode: "targets",
targets: [
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
],
});
});
it("routes reply runtime SDK exports through plugin SDK contract tests", () => {
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/reply-runtime.ts"])).toEqual({
mode: "targets",
targets: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
});
});
it("keeps extension batch runner edits on extension script tests", () => {
expect(resolveChangedTestTargetPlan(["scripts/test-extension-batch.mjs"])).toEqual({
mode: "targets",
@@ -465,7 +537,12 @@ describe("scripts/test-projects changed-target routing", () => {
).toEqual({
mode: "targets",
targets: [
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.test.ts",
"src/auto-reply/reply/followup-runner.test.ts",
"src/auto-reply/reply/groups.test.ts",
"extensions/discord/src/monitor/message-handler.process.test.ts",
"extensions/slack/src/monitor.tool-result.test.ts",
"src/auto-reply/reply/effective-reply-route.test.ts",
],
});

View File

@@ -0,0 +1,76 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
evaluateTestboxSyncSanity,
parseGitShortStatus,
} from "../../scripts/testbox-sync-sanity.mjs";
describe("testbox sync sanity", () => {
it("parses tracked deletions from git short status", () => {
expect(
parseGitShortStatus(
" D pnpm-lock.yaml\nD package.json\n?? scratch.txt\nR old.ts -> new.ts\n",
),
).toEqual([
{
line: " D pnpm-lock.yaml",
path: "pnpm-lock.yaml",
status: " D",
trackedDeletion: true,
},
{
line: "D package.json",
path: "package.json",
status: "D ",
trackedDeletion: true,
},
{
line: "?? scratch.txt",
path: "scratch.txt",
status: "??",
trackedDeletion: false,
},
{
line: "R old.ts -> new.ts",
path: "new.ts",
status: "R ",
trackedDeletion: false,
},
]);
});
it("fails before a gate when critical repo files disappeared", () => {
const result = evaluateTestboxSyncSanity({
cwd: "/repo",
statusRaw: "",
exists: (file) => path.basename(file) !== "pnpm-lock.yaml",
});
expect(result.ok).toBe(false);
expect(result.problems).toContain("missing required root files: pnpm-lock.yaml");
});
it("fails on mass tracked deletions unless explicitly allowed", () => {
const statusRaw = Array.from({ length: 3 }, (_, index) => ` D file-${index}.ts`).join("\n");
const result = evaluateTestboxSyncSanity({
cwd: "/repo",
statusRaw,
deletionThreshold: 3,
exists: () => true,
});
expect(result.ok).toBe(false);
expect(result.trackedDeletionCount).toBe(3);
expect(result.problems[0]).toContain("remote git status has 3 tracked deletions");
expect(
evaluateTestboxSyncSanity({
cwd: "/repo",
statusRaw,
deletionThreshold: 3,
allowMassDeletions: true,
exists: () => true,
}).ok,
).toBe(true);
});
});