From 67b16a4a6d25ea31a4cc021c3663b26aa2a6c34f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 09:13:49 +0100 Subject: [PATCH] fix: centralize source reply delivery mode --- .agents/skills/blacksmith-testbox/SKILL.md | 16 +- .agents/skills/openclaw-testing/SKILL.md | 3 + docs/ci.md | 23 +++ docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-subpaths.md | 2 +- .../monitor/message-handler.process.test.ts | 3 +- .../src/monitor/message-handler.process.ts | 17 +- .../slack/src/monitor.tool-result.test.ts | 47 ++++++ .../dispatch.preview-fallback.test.ts | 16 ++ .../src/monitor/message-handler/dispatch.ts | 150 ++++++++++-------- .../src/monitor/message-handler/prepare.ts | 11 +- package.json | 1 + scripts/test-projects.test-support.mjs | 31 +++- scripts/testbox-sync-sanity.mjs | 110 +++++++++++++ .../reply/dispatch-from-config.test.ts | 27 ++++ src/auto-reply/reply/dispatch-from-config.ts | 18 +-- .../reply/source-reply-delivery-mode.ts | 24 +++ src/plugin-sdk/channel-reply-pipeline.ts | 15 ++ .../contracts/plugin-sdk-subpaths.test.ts | 2 + test/scripts/test-projects.test.ts | 77 +++++++++ test/scripts/testbox-sync-sanity.test.ts | 76 +++++++++ 21 files changed, 568 insertions(+), 103 deletions(-) create mode 100644 scripts/testbox-sync-sanity.mjs create mode 100644 src/auto-reply/reply/source-reply-delivery-mode.ts create mode 100644 test/scripts/testbox-sync-sanity.test.ts diff --git a/.agents/skills/blacksmith-testbox/SKILL.md b/.agents/skills/blacksmith-testbox/SKILL.md index af3d3159565..4d3a1b34228 100644 --- a/.agents/skills/blacksmith-testbox/SKILL.md +++ b/.agents/skills/blacksmith-testbox/SKILL.md @@ -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 "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 "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 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 diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index 7f447d49df6..5a762d3a3de 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -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. diff --git a/docs/ci.md b/docs/ci.md index 5fb0234175a..a9dbdd6b3b7 100644 --- a/docs/ci.md +++ b/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. diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 82aab4afcc9..cd30a71c1fe 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -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 | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index c025cebed59..5911edaf6c2 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 3e074667c1a..38ff39aee70 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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 () => { diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 05dc1ce18f7..85b5bf640ae 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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({ diff --git a/extensions/slack/src/monitor.tool-result.test.ts b/extensions/slack/src/monitor.tool-result.test.ts index 222b980fd4b..28b3ac3c7ff 100644 --- a/extensions/slack/src/monitor.tool-result.test.ts +++ b/extensions/slack/src/monitor.tool-result.test.ts @@ -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"); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 7cacbdea7ec..c7b2d91f69b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -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", () => ({ diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 56ed023a47c..a0b26d4e358 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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, diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 06327a6a790..6b60e9a7db6 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -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}` diff --git a/package.json b/package.json index ff0b70d46b6..0d311a23fc5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 96ab1c335d7..3f6e32f7ebe 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/scripts/testbox-sync-sanity.mjs b/scripts/testbox-sync-sanity.mjs new file mode 100644 index 00000000000..6499332d2bf --- /dev/null +++ b/scripts/testbox-sync-sanity.mjs @@ -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(); +} diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index ae108905ca3..6c9ca805f50 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 674b7b9b57a..bdf45b6c750 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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 | 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, diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts new file mode 100644 index 00000000000..c0d48be72d5 --- /dev/null +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -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"; +} diff --git a/src/plugin-sdk/channel-reply-pipeline.ts b/src/plugin-sdk/channel-reply-pipeline.ts index 8516e28b9ec..f2935fd62a0 100644 --- a/src/plugin-sdk/channel-reply-pipeline.ts +++ b/src/plugin-sdk/channel-reply-pipeline.ts @@ -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; diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 160bf2cb578..a19cabf04a7 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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()) { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index d8335aa20d2..985db7a79af 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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", ], }); diff --git a/test/scripts/testbox-sync-sanity.test.ts b/test/scripts/testbox-sync-sanity.test.ts new file mode 100644 index 00000000000..26bd7016a92 --- /dev/null +++ b/test/scripts/testbox-sync-sanity.test.ts @@ -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); + }); +});