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. 5. If tests fail, fix code and re-run against the same warm box.
6. If you changed dependency manifests (package.json, etc.), prepend 6. If you changed dependency manifests (package.json, etc.), prepend
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"` 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/` `blacksmith testbox download --id <ID> coverage/ ./coverage/`
8. Once green, commit and push. 9. Once green, commit and push.
## OpenClaw full test suite ## 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 `pnpm check:changed` inside the warmed box first when appropriate, then the full
suite with the profile above if broad confidence is needed. 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 ## Examples
blacksmith testbox warmup ci-check-testbox.yml 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, - Direct test edits run themselves. Source edits prefer explicit mappings,
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
edits are skipped by default unless they have precise mapped tests. 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. - Public SDK or contract edits do not automatically run every plugin test.
`check:changed` proves extension type contracts; the agent chooses the `check:changed` proves extension type contracts; the agent chooses the
smallest plugin/contract Vitest proof that matches the actual risk. 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 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 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. 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 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 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. 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-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. 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/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-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-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-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/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 | | `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-resolution` | Account lookup + default-fallback helpers |
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers | | `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
| `plugin-sdk/channel-pairing` | `createChannelPairingController` | | `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-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder | | `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 | | `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 processDiscordMessage(ctx as any);
await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled());
expectRemoveAckCallAt(0, "👀", { expectRemoveAckCallAt(0, "👀", {
accountId: "default", accountId: "default",
ackReaction: "👀", ackReaction: "👀",
@@ -861,7 +862,7 @@ describe("processDiscordMessage session routing", () => {
...createDirectMessageContextOverrides(), ...createDirectMessageContextOverrides(),
})) as any, })) as any,
); );
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBeUndefined(); expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
}); });
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => { it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {

View File

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

View File

@@ -230,6 +230,7 @@ describe("monitorSlackProvider tool results", () => {
responsePrefix: "PFX", responsePrefix: "PFX",
ackReaction: "👀", ackReaction: "👀",
ackReactionScope: "group-mentions", ackReactionScope: "group-mentions",
groupChat: { visibleReplies: "automatic" },
removeAckAfterReply: true, removeAckAfterReply: true,
statusReactions: statusReactionsEnabled statusReactions: statusReactionsEnabled
? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } } ? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } }
@@ -521,6 +522,38 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock).toHaveBeenCalledTimes(1); 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 () => { it("treats control commands as mentions for group bypass", async () => {
replyMock.mockResolvedValue({ text: "ok" }); replyMock.mockResolvedValue({ text: "ok" });
await runChannelMessageEvent("/elevated off"); await runChannelMessageEvent("/elevated off");
@@ -584,6 +617,20 @@ describe("monitorSlackProvider tool results", () => {
it("reacts to mention-gated room messages when ackReaction is enabled", async () => { it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
replyMock.mockResolvedValue(undefined); 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(); const client = getSlackClient();
if (!client) { if (!client) {
throw new Error("Slack client not registered"); throw new Error("Slack client not registered");

View File

@@ -146,6 +146,22 @@ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
}, },
onModelSelected: undefined, 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", () => ({ vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({

View File

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

View File

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

View File

@@ -1556,6 +1556,7 @@
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs", "test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
"test:watch": "node scripts/test-projects.mjs --watch", "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", "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:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write", "tool-display:write": "node --import tsx scripts/tool-display.ts --write",
"ts-topology": "node --import tsx scripts/ts-topology.ts", "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.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.d.mts", ["test/scripts/test-projects.test.ts"]],
["scripts/test-projects.test-support.mjs", ["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([ const TOOLING_TEST_TARGETS = new Map([
["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]], ["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/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/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/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",
["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([ const SOURCE_TEST_TARGETS = new Map([
...PRECISE_SOURCE_TEST_TARGETS, ...PRECISE_SOURCE_TEST_TARGETS,
[ [
@@ -271,6 +285,11 @@ const SOURCE_TEST_TARGETS = new Map([
"extensions/telegram/src/directory-contract.test.ts", "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", "test/helpers/channels/directory-ids.ts",
[ [
@@ -306,10 +325,8 @@ const SOURCE_TEST_TARGETS = new Map([
"extensions/telegram/src/directory-contract.test.ts", "extensions/telegram/src/directory-contract.test.ts",
], ],
], ],
[ ["src/auto-reply/reply/dispatch-from-config.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
"src/auto-reply/reply/dispatch-from-config.ts", ["src/auto-reply/reply/source-reply-delivery-mode.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
["src/auto-reply/reply/dispatch-from-config.test.ts"],
],
[ [
"src/auto-reply/reply/effective-reply-route.ts", "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/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/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
[ [
"src/auto-reply/reply/dispatch-acp-command-bypass.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; } as const satisfies OpenClawConfig;
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; 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 resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook; let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
type DispatchReplyArgs = Parameters< type DispatchReplyArgs = Parameters<
@@ -498,6 +499,7 @@ type DispatchReplyArgs = Parameters<
beforeAll(async () => { beforeAll(async () => {
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ resolveSourceReplyDeliveryMode } = await import("./source-reply-delivery-mode.js"));
await import("./dispatch-acp.js"); await import("./dispatch-acp.js");
await import("./dispatch-acp-command-bypass.js"); await import("./dispatch-acp-command-bypass.js");
await import("./dispatch-acp-tts.runtime.js"); await import("./dispatch-acp-tts.runtime.js");
@@ -3867,6 +3869,31 @@ describe("before_dispatch hook", () => {
}); });
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => { 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(() => { beforeEach(() => {
resetInboundDedupe(); resetInboundDedupe();
sessionBindingMocks.resolveByConversation.mockReset(); 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 { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js"; import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
import { resolveReplyRoutingDecision } from "./routing-policy.js"; import { resolveReplyRoutingDecision } from "./routing-policy.js";
import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js";
import { resolveRunTypingPolicy } from "./typing-policy.js"; import { resolveRunTypingPolicy } from "./typing-policy.js";
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null; let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
@@ -193,23 +194,6 @@ const resolveRoutedPolicyConversationType = (
return undefined; 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 = ( const resolveSessionStoreLookup = (
ctx: FinalizedMsgContext, ctx: FinalizedMsgContext,
cfg: OpenClawConfig, 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 { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { import {
createReplyPrefixContext, createReplyPrefixContext,
@@ -10,12 +15,22 @@ import {
type CreateTypingCallbacksParams, type CreateTypingCallbacksParams,
type TypingCallbacks, type TypingCallbacks,
} from "../channels/typing.js"; } from "../channels/typing.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ReplyPayload } from "./reply-payload.js"; import type { ReplyPayload } from "./reply-payload.js";
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"]; export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
export type { ReplyPrefixContextBundle, ReplyPrefixOptions }; export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
export type { CreateTypingCallbacksParams, TypingCallbacks }; export type { CreateTypingCallbacksParams, TypingCallbacks };
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks }; 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 & { export type ChannelReplyPipeline = ReplyPrefixOptions & {
typingCallbacks?: TypingCallbacks; typingCallbacks?: TypingCallbacks;

View File

@@ -1316,10 +1316,12 @@ describe("plugin-sdk subpath exports", () => {
"createTypingCallbacks", "createTypingCallbacks",
"createReplyPrefixContext", "createReplyPrefixContext",
"createReplyPrefixOptions", "createReplyPrefixOptions",
"resolveChannelSourceReplyDeliveryMode",
]); ]);
expect(typeof channelReplyPipelineSdk.createTypingCallbacks).toBe("function"); expect(typeof channelReplyPipelineSdk.createTypingCallbacks).toBe("function");
expect(typeof channelReplyPipelineSdk.createReplyPrefixContext).toBe("function"); expect(typeof channelReplyPipelineSdk.createReplyPrefixContext).toBe("function");
expect(typeof channelReplyPipelineSdk.createReplyPrefixOptions).toBe("function"); expect(typeof channelReplyPipelineSdk.createReplyPrefixOptions).toBe("function");
expect(typeof channelReplyPipelineSdk.resolveChannelSourceReplyDeliveryMode).toBe("function");
expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length); expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length);
for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) { 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", () => { it("keeps extension batch runner edits on extension script tests", () => {
expect(resolveChangedTestTargetPlan(["scripts/test-extension-batch.mjs"])).toEqual({ expect(resolveChangedTestTargetPlan(["scripts/test-extension-batch.mjs"])).toEqual({
mode: "targets", mode: "targets",
@@ -465,7 +537,12 @@ describe("scripts/test-projects changed-target routing", () => {
).toEqual({ ).toEqual({
mode: "targets", mode: "targets",
targets: [ targets: [
"src/auto-reply/reply/dispatch-acp.test.ts",
"src/auto-reply/reply/dispatch-from-config.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", "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);
});
});