mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
fix: centralize source reply delivery mode
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
23
docs/ci.md
23
docs/ci.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
110
scripts/testbox-sync-sanity.mjs
Normal file
110
scripts/testbox-sync-sanity.mjs
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
src/auto-reply/reply/source-reply-delivery-mode.ts
Normal file
24
src/auto-reply/reply/source-reply-delivery-mode.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
|
||||
76
test/scripts/testbox-sync-sanity.test.ts
Normal file
76
test/scripts/testbox-sync-sanity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user