mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +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.
|
5. If tests fail, fix code and re-run against the same warm box.
|
||||||
6. If you changed dependency manifests (package.json, etc.), prepend
|
6. If you changed dependency manifests (package.json, etc.), prepend
|
||||||
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
|
the install command: `blacksmith testbox run --id <ID> "npm install && npm test"`
|
||||||
7. If you need artifacts (coverage reports, build outputs, etc.), download them:
|
7. If a narrow PR reports a full sync or the box was reused/expired, sanity
|
||||||
|
check the remote copy before a slow gate:
|
||||||
|
`blacksmith testbox run --id <ID> "pnpm testbox:sanity"`.
|
||||||
|
If it reports missing root files or mass tracked deletions, stop the box and
|
||||||
|
warm a fresh one. Use `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` only for an
|
||||||
|
intentional large deletion PR.
|
||||||
|
8. If you need artifacts (coverage reports, build outputs, etc.), download them:
|
||||||
`blacksmith testbox download --id <ID> coverage/ ./coverage/`
|
`blacksmith testbox download --id <ID> coverage/ ./coverage/`
|
||||||
8. Once green, commit and push.
|
9. Once green, commit and push.
|
||||||
|
|
||||||
## OpenClaw full test suite
|
## OpenClaw full test suite
|
||||||
|
|
||||||
@@ -314,6 +320,12 @@ When validating before commit/push in maintainer Testbox mode, run
|
|||||||
`pnpm check:changed` inside the warmed box first when appropriate, then the full
|
`pnpm check:changed` inside the warmed box first when appropriate, then the full
|
||||||
suite with the profile above if broad confidence is needed.
|
suite with the profile above if broad confidence is needed.
|
||||||
|
|
||||||
|
Run `pnpm testbox:sanity` inside the warmed box before the broad command when
|
||||||
|
the sync looks suspicious. It checks that root files such as `pnpm-lock.yaml`
|
||||||
|
still exist and fails on 200 or more tracked deletions. That catches stale or
|
||||||
|
corrupted rsync state before dependency install or Vitest failures hide the real
|
||||||
|
problem.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
blacksmith testbox warmup ci-check-testbox.yml
|
blacksmith testbox warmup ci-check-testbox.yml
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
|
|||||||
- Direct test edits run themselves. Source edits prefer explicit mappings,
|
- Direct test edits run themselves. Source edits prefer explicit mappings,
|
||||||
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
|
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
|
||||||
edits are skipped by default unless they have precise mapped tests.
|
edits are skipped by default unless they have precise mapped tests.
|
||||||
|
- Shared group-room delivery config and source-reply prompt edits are precise
|
||||||
|
mapped tests: they run the core auto-reply regressions plus Discord and Slack
|
||||||
|
delivery tests so cross-channel default changes fail before a PR push.
|
||||||
- Public SDK or contract edits do not automatically run every plugin test.
|
- Public SDK or contract edits do not automatically run every plugin test.
|
||||||
`check:changed` proves extension type contracts; the agent chooses the
|
`check:changed` proves extension type contracts; the agent chooses the
|
||||||
smallest plugin/contract Vitest proof that matches the actual risk.
|
smallest plugin/contract Vitest proof that matches the actual risk.
|
||||||
|
|||||||
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
|
runs the release-critical QA Lab lanes before release approval; its QA parity
|
||||||
gate runs the candidate and baseline packs as parallel lane jobs, then downloads
|
gate runs the candidate and baseline packs as parallel lane jobs, then downloads
|
||||||
both artifacts into a small report job for the final parity comparison.
|
both artifacts into a small report job for the final parity comparison.
|
||||||
|
Do not put the PR landing path behind `Parity gate` unless the change actually
|
||||||
|
touches QA runtime, model-pack parity, or a surface the parity workflow owns.
|
||||||
|
For normal channel, config, docs, or unit-test fixes, treat it as an optional
|
||||||
|
signal and follow the scoped CI/check evidence instead.
|
||||||
|
|
||||||
The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for
|
The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for
|
||||||
post-land duplicate cleanup. It defaults to dry-run and only closes explicitly
|
post-land duplicate cleanup. It defaults to dry-run and only closes explicitly
|
||||||
@@ -330,6 +334,25 @@ The separate `install-smoke` workflow reuses the same scope script through its o
|
|||||||
Current release Docker chunks are `core`, `package-update-openai`, `package-update-anthropic`, `package-update-core`, `plugins-runtime-plugins`, `plugins-runtime-services`, `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate `bundled-channels` chunk remains available for manual one-shot reruns, and `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` remain aggregate plugin/runtime aliases, but the release workflow uses the split chunks so channel smokes, update targets, plugin runtime checks, and bundled plugin install/uninstall sweeps can run in parallel. Targeted `docker_lanes` dispatches also split multiple selected lanes into parallel jobs after one shared package/image preparation step, and bundled-channel update lanes retry once for transient npm network failures.
|
Current release Docker chunks are `core`, `package-update-openai`, `package-update-anthropic`, `package-update-core`, `plugins-runtime-plugins`, `plugins-runtime-services`, `plugins-runtime-install-a`, `plugins-runtime-install-b`, `plugins-runtime-install-c`, `plugins-runtime-install-d`, `bundled-channels-core`, `bundled-channels-update-a`, `bundled-channels-update-b`, and `bundled-channels-contracts`. The aggregate `bundled-channels` chunk remains available for manual one-shot reruns, and `plugins-runtime-core`, `plugins-runtime`, and `plugins-integrations` remain aggregate plugin/runtime aliases, but the release workflow uses the split chunks so channel smokes, update targets, plugin runtime checks, and bundled plugin install/uninstall sweeps can run in parallel. Targeted `docker_lanes` dispatches also split multiple selected lanes into parallel jobs after one shared package/image preparation step, and bundled-channel update lanes retry once for transient npm network failures.
|
||||||
|
|
||||||
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local check gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod and core test typecheck plus core lint/guards, core test-only changes run only core test typecheck plus core lint, extension production changes run extension prod and extension test typecheck plus extension lint, and extension test-only changes run extension test typecheck plus extension lint. Public Plugin SDK or plugin-contract changes expand to extension typecheck because extensions depend on those core contracts, but Vitest extension sweeps are explicit test work. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all check lanes.
|
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local check gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod and core test typecheck plus core lint/guards, core test-only changes run only core test typecheck plus core lint, extension production changes run extension prod and extension test typecheck plus extension lint, and extension test-only changes run extension test typecheck plus extension lint. Public Plugin SDK or plugin-contract changes expand to extension typecheck because extensions depend on those core contracts, but Vitest extension sweeps are explicit test work. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all check lanes.
|
||||||
|
Local changed-test routing lives in `scripts/test-projects.test-support.mjs` and
|
||||||
|
is intentionally cheaper than `check:changed`: direct test edits run themselves,
|
||||||
|
source edits prefer explicit mappings, then sibling tests and import-graph
|
||||||
|
dependents. Shared group-room delivery config is one of the explicit mappings:
|
||||||
|
changes to the group visible-reply config, source reply delivery mode, or the
|
||||||
|
message-tool system prompt route through the core reply tests plus Discord and
|
||||||
|
Slack delivery regressions so a shared default change fails before the first PR
|
||||||
|
push. Use `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` only when the change
|
||||||
|
is harness-wide enough that the cheap mapped set is not a trustworthy proxy.
|
||||||
|
|
||||||
|
For Testbox validation, run from the repo root and prefer a fresh warmed box for
|
||||||
|
broad proof. Before spending a slow gate on a box that was reused, expired, or
|
||||||
|
just reported an unexpectedly large sync, run `pnpm testbox:sanity` inside the
|
||||||
|
box first. The sanity check fails fast when required root files such as
|
||||||
|
`pnpm-lock.yaml` disappeared or when `git status --short` shows at least 200
|
||||||
|
tracked deletions. That usually means the remote sync state is not a trustworthy
|
||||||
|
copy of the PR. Stop that box and warm a fresh one instead of debugging the
|
||||||
|
product test failure. For intentional large deletion PRs, set
|
||||||
|
`OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` for that sanity run.
|
||||||
|
|
||||||
Manual CI dispatches run `checks-node-compat-node22` as release-candidate compatibility coverage. Normal pull requests and `main` pushes skip that lane and keep the matrix focused on the Node 24 test/channel lanes.
|
Manual CI dispatches run `checks-node-compat-node22` as release-candidate compatibility coverage. Normal pull requests and `main` pushes skip that lane and keep the matrix focused on the Node 24 test/channel lanes.
|
||||||
|
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ releases.
|
|||||||
| `plugin-sdk/account-helpers` | Narrow account helpers | Account list/account-action helpers |
|
| `plugin-sdk/account-helpers` | Narrow account helpers | Account list/account-action helpers |
|
||||||
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` |
|
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` |
|
||||||
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
|
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
|
||||||
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
|
| `plugin-sdk/channel-reply-pipeline` | Reply prefix, typing, and source-delivery wiring | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
|
||||||
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` |
|
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` |
|
||||||
| `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives and the generic builder only |
|
| `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives and the generic builder only |
|
||||||
| `plugin-sdk/bundled-channel-config-schema` | Bundled config schemas | OpenClaw-maintained bundled plugins only; new plugins must define plugin-local schemas |
|
| `plugin-sdk/bundled-channel-config-schema` | Bundled config schemas | OpenClaw-maintained bundled plugins only; new plugins must define plugin-local schemas |
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
|||||||
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
|
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
|
||||||
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
|
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
|
||||||
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
|
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
|
||||||
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline` |
|
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
|
||||||
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
|
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
|
||||||
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder |
|
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives and generic builder |
|
||||||
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
|
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
|
||||||
|
|||||||
@@ -675,6 +675,7 @@ describe("processDiscordMessage ack reactions", () => {
|
|||||||
|
|
||||||
await processDiscordMessage(ctx as any);
|
await processDiscordMessage(ctx as any);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled());
|
||||||
expectRemoveAckCallAt(0, "👀", {
|
expectRemoveAckCallAt(0, "👀", {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -861,7 +862,7 @@ describe("processDiscordMessage session routing", () => {
|
|||||||
...createDirectMessageContextOverrides(),
|
...createDirectMessageContextOverrides(),
|
||||||
})) as any,
|
})) as any,
|
||||||
);
|
);
|
||||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBeUndefined();
|
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
|
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
resolveEnvelopeFormatOptions,
|
resolveEnvelopeFormatOptions,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
|
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
import {
|
||||||
|
createChannelReplyPipeline,
|
||||||
|
resolveChannelSourceReplyDeliveryMode,
|
||||||
|
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||||
import {
|
import {
|
||||||
resolveChannelStreamingBlockEnabled,
|
resolveChannelStreamingBlockEnabled,
|
||||||
resolveChannelStreamingPreviewToolProgress,
|
resolveChannelStreamingPreviewToolProgress,
|
||||||
@@ -206,11 +209,11 @@ export async function processDiscordMessage(
|
|||||||
if (boundThreadId && typeof threadBindings.touchThread === "function") {
|
if (boundThreadId && typeof threadBindings.touchThread === "function") {
|
||||||
threadBindings.touchThread({ threadId: boundThreadId });
|
threadBindings.touchThread({ threadId: boundThreadId });
|
||||||
}
|
}
|
||||||
const sourceReplyDeliveryMode = isGuildMessage
|
const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime();
|
||||||
? cfg.messages?.groupChat?.visibleReplies === "automatic"
|
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
|
||||||
? ("automatic" as const)
|
cfg,
|
||||||
: ("message_tool_only" as const)
|
ctx: { ChatType: isGuildMessage ? "channel" : undefined },
|
||||||
: undefined;
|
});
|
||||||
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
|
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
|
||||||
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@@ -279,8 +282,6 @@ export async function processDiscordMessage(
|
|||||||
reactionAdapter: discordAdapter,
|
reactionAdapter: discordAdapter,
|
||||||
target: `${messageChannelId}/${message.id}`,
|
target: `${messageChannelId}/${message.id}`,
|
||||||
});
|
});
|
||||||
const { createReplyDispatcherWithTyping, dispatchInboundMessage } = await loadReplyRuntime();
|
|
||||||
|
|
||||||
const fromLabel = isDirectMessage
|
const fromLabel = isDirectMessage
|
||||||
? buildDirectLabel(author)
|
? buildDirectLabel(author)
|
||||||
: buildGuildLabel({
|
: buildGuildLabel({
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
ackReactionScope: "group-mentions",
|
ackReactionScope: "group-mentions",
|
||||||
|
groupChat: { visibleReplies: "automatic" },
|
||||||
removeAckAfterReply: true,
|
removeAckAfterReply: true,
|
||||||
statusReactions: statusReactionsEnabled
|
statusReactions: statusReactionsEnabled
|
||||||
? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } }
|
? { enabled: true, timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 } }
|
||||||
@@ -521,6 +522,38 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps always-on channel messages private by default", async () => {
|
||||||
|
slackTestState.config = {
|
||||||
|
messages: {
|
||||||
|
ackReaction: "👀",
|
||||||
|
ackReactionScope: "all",
|
||||||
|
statusReactions: {
|
||||||
|
enabled: true,
|
||||||
|
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
groupPolicy: "open",
|
||||||
|
requireMention: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
replyMock.mockResolvedValue({ text: "quiet" });
|
||||||
|
|
||||||
|
await runSlackMessageOnce(monitorSlackProvider, {
|
||||||
|
event: makeSlackMessageEvent({
|
||||||
|
channel_type: "channel",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock).not.toHaveBeenCalled();
|
||||||
|
expect(reactMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("treats control commands as mentions for group bypass", async () => {
|
it("treats control commands as mentions for group bypass", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "ok" });
|
replyMock.mockResolvedValue({ text: "ok" });
|
||||||
await runChannelMessageEvent("/elevated off");
|
await runChannelMessageEvent("/elevated off");
|
||||||
@@ -584,6 +617,20 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
|
|
||||||
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated room messages when ackReaction is enabled", async () => {
|
||||||
replyMock.mockResolvedValue(undefined);
|
replyMock.mockResolvedValue(undefined);
|
||||||
|
slackTestState.config = {
|
||||||
|
messages: {
|
||||||
|
responsePrefix: "PFX",
|
||||||
|
ackReaction: "👀",
|
||||||
|
ackReactionScope: "group-mentions",
|
||||||
|
groupChat: { visibleReplies: "automatic" },
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
groupPolicy: "open",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
const client = getSlackClient();
|
const client = getSlackClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Slack client not registered");
|
throw new Error("Slack client not registered");
|
||||||
|
|||||||
@@ -146,6 +146,22 @@ vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
|
|||||||
},
|
},
|
||||||
onModelSelected: undefined,
|
onModelSelected: undefined,
|
||||||
}),
|
}),
|
||||||
|
resolveChannelSourceReplyDeliveryMode: (params: {
|
||||||
|
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
|
||||||
|
ctx?: { ChatType?: string };
|
||||||
|
requested?: "automatic" | "message_tool_only";
|
||||||
|
}) => {
|
||||||
|
if (params.requested) {
|
||||||
|
return params.requested;
|
||||||
|
}
|
||||||
|
const chatType = params.ctx?.ChatType;
|
||||||
|
if (chatType === "group" || chatType === "channel") {
|
||||||
|
return params.cfg?.messages?.groupChat?.visibleReplies === "automatic"
|
||||||
|
? "automatic"
|
||||||
|
: "message_tool_only";
|
||||||
|
}
|
||||||
|
return "automatic";
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
|
vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
type StatusReactionAdapter,
|
type StatusReactionAdapter,
|
||||||
} from "openclaw/plugin-sdk/channel-feedback";
|
} from "openclaw/plugin-sdk/channel-feedback";
|
||||||
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
|
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
import {
|
||||||
|
createChannelReplyPipeline,
|
||||||
|
resolveChannelSourceReplyDeliveryMode,
|
||||||
|
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||||
import {
|
import {
|
||||||
resolveChannelStreamingBlockEnabled,
|
resolveChannelStreamingBlockEnabled,
|
||||||
resolveChannelStreamingNativeTransport,
|
resolveChannelStreamingNativeTransport,
|
||||||
@@ -282,12 +285,18 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
message,
|
message,
|
||||||
replyToMode: prepared.replyToMode,
|
replyToMode: prepared.replyToMode,
|
||||||
});
|
});
|
||||||
|
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
|
||||||
|
cfg,
|
||||||
|
ctx: prepared.ctxPayload,
|
||||||
|
});
|
||||||
|
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
|
||||||
|
|
||||||
const reactionMessageTs = prepared.ackReactionMessageTs;
|
const reactionMessageTs = prepared.ackReactionMessageTs;
|
||||||
const messageTs = message.ts ?? message.event_ts;
|
const messageTs = message.ts ?? message.event_ts;
|
||||||
const incomingThreadTs = message.thread_ts;
|
const incomingThreadTs = message.thread_ts;
|
||||||
let didSetStatus = false;
|
let didSetStatus = false;
|
||||||
const statusReactionsEnabled =
|
const statusReactionsEnabled =
|
||||||
|
!sourceRepliesAreToolOnly &&
|
||||||
Boolean(prepared.ackReactionPromise) &&
|
Boolean(prepared.ackReactionPromise) &&
|
||||||
Boolean(reactionMessageTs) &&
|
Boolean(reactionMessageTs) &&
|
||||||
cfg.messages?.statusReactions?.enabled !== false;
|
cfg.messages?.statusReactions?.enabled !== false;
|
||||||
@@ -361,57 +370,59 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId })
|
isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId })
|
||||||
? compileSlackInteractiveReplies(payload)
|
? compileSlackInteractiveReplies(payload)
|
||||||
: payload,
|
: payload,
|
||||||
typing: {
|
typing: sourceRepliesAreToolOnly
|
||||||
start: async () => {
|
? undefined
|
||||||
didSetStatus = true;
|
: {
|
||||||
await ctx.setSlackThreadStatus({
|
start: async () => {
|
||||||
channelId: message.channel,
|
didSetStatus = true;
|
||||||
threadTs: statusThreadTs,
|
await ctx.setSlackThreadStatus({
|
||||||
status: "is typing...",
|
channelId: message.channel,
|
||||||
});
|
threadTs: statusThreadTs,
|
||||||
if (typingReaction && message.ts) {
|
status: "is typing...",
|
||||||
await reactSlackMessage(message.channel, message.ts, typingReaction, {
|
});
|
||||||
token: ctx.botToken,
|
if (typingReaction && message.ts) {
|
||||||
client: ctx.app.client,
|
await reactSlackMessage(message.channel, message.ts, typingReaction, {
|
||||||
}).catch(() => {});
|
token: ctx.botToken,
|
||||||
}
|
client: ctx.app.client,
|
||||||
},
|
}).catch(() => {});
|
||||||
stop: async () => {
|
}
|
||||||
if (!didSetStatus) {
|
},
|
||||||
return;
|
stop: async () => {
|
||||||
}
|
if (!didSetStatus) {
|
||||||
didSetStatus = false;
|
return;
|
||||||
await ctx.setSlackThreadStatus({
|
}
|
||||||
channelId: message.channel,
|
didSetStatus = false;
|
||||||
threadTs: statusThreadTs,
|
await ctx.setSlackThreadStatus({
|
||||||
status: "",
|
channelId: message.channel,
|
||||||
});
|
threadTs: statusThreadTs,
|
||||||
if (typingReaction && message.ts) {
|
status: "",
|
||||||
await removeSlackReaction(message.channel, message.ts, typingReaction, {
|
});
|
||||||
token: ctx.botToken,
|
if (typingReaction && message.ts) {
|
||||||
client: ctx.app.client,
|
await removeSlackReaction(message.channel, message.ts, typingReaction, {
|
||||||
}).catch(() => {});
|
token: ctx.botToken,
|
||||||
}
|
client: ctx.app.client,
|
||||||
},
|
}).catch(() => {});
|
||||||
onStartError: (err) => {
|
}
|
||||||
logTypingFailure({
|
},
|
||||||
log: (message) => runtime.error?.(danger(message)),
|
onStartError: (err) => {
|
||||||
channel: "slack",
|
logTypingFailure({
|
||||||
action: "start",
|
log: (message) => runtime.error?.(danger(message)),
|
||||||
target: typingTarget,
|
channel: "slack",
|
||||||
error: err,
|
action: "start",
|
||||||
});
|
target: typingTarget,
|
||||||
},
|
error: err,
|
||||||
onStopError: (err) => {
|
});
|
||||||
logTypingFailure({
|
},
|
||||||
log: (message) => runtime.error?.(danger(message)),
|
onStopError: (err) => {
|
||||||
channel: "slack",
|
logTypingFailure({
|
||||||
action: "stop",
|
log: (message) => runtime.error?.(danger(message)),
|
||||||
target: typingTarget,
|
channel: "slack",
|
||||||
error: err,
|
action: "stop",
|
||||||
});
|
target: typingTarget,
|
||||||
},
|
error: err,
|
||||||
},
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const slackStreaming = resolveSlackStreamingConfig({
|
const slackStreaming = resolveSlackStreamingConfig({
|
||||||
@@ -424,15 +435,19 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
messageTs,
|
messageTs,
|
||||||
isThreadReply,
|
isThreadReply,
|
||||||
});
|
});
|
||||||
const previewStreamingEnabled = shouldEnableSlackPreviewStreaming({
|
const previewStreamingEnabled =
|
||||||
mode: slackStreaming.mode,
|
!sourceRepliesAreToolOnly &&
|
||||||
isDirectMessage: prepared.isDirectMessage,
|
shouldEnableSlackPreviewStreaming({
|
||||||
threadTs: streamThreadHint,
|
mode: slackStreaming.mode,
|
||||||
});
|
isDirectMessage: prepared.isDirectMessage,
|
||||||
const streamingEnabled = isSlackStreamingEnabled({
|
threadTs: streamThreadHint,
|
||||||
mode: slackStreaming.mode,
|
});
|
||||||
nativeStreaming: slackStreaming.nativeStreaming,
|
const streamingEnabled =
|
||||||
});
|
!sourceRepliesAreToolOnly &&
|
||||||
|
isSlackStreamingEnabled({
|
||||||
|
mode: slackStreaming.mode,
|
||||||
|
nativeStreaming: slackStreaming.nativeStreaming,
|
||||||
|
});
|
||||||
const useStreaming = shouldUseStreaming({
|
const useStreaming = shouldUseStreaming({
|
||||||
streamingEnabled,
|
streamingEnabled,
|
||||||
threadTs: streamThreadHint,
|
threadTs: streamThreadHint,
|
||||||
@@ -442,11 +457,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
useStreaming,
|
useStreaming,
|
||||||
});
|
});
|
||||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
|
||||||
const disableBlockStreaming = resolveSlackDisableBlockStreaming({
|
const disableBlockStreaming = sourceRepliesAreToolOnly
|
||||||
useStreaming,
|
? true
|
||||||
shouldUseDraftStream,
|
: resolveSlackDisableBlockStreaming({
|
||||||
blockStreamingEnabled,
|
useStreaming,
|
||||||
});
|
shouldUseDraftStream,
|
||||||
|
blockStreamingEnabled,
|
||||||
|
});
|
||||||
let streamSession: SlackStreamSession | null = null;
|
let streamSession: SlackStreamSession | null = null;
|
||||||
let streamFailed = false;
|
let streamFailed = false;
|
||||||
let usedReplyThreadTs: string | undefined;
|
let usedReplyThreadTs: string | undefined;
|
||||||
@@ -967,6 +984,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
replyOptions: {
|
replyOptions: {
|
||||||
...replyOptions,
|
...replyOptions,
|
||||||
skillFilter: prepared.channelConfig?.skills,
|
skillFilter: prepared.channelConfig?.skills,
|
||||||
|
sourceReplyDeliveryMode,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
disableBlockStreaming,
|
disableBlockStreaming,
|
||||||
onModelSelected,
|
onModelSelected,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveEnvelopeFormatOptions,
|
resolveEnvelopeFormatOptions,
|
||||||
resolveInboundMentionDecision,
|
resolveInboundMentionDecision,
|
||||||
} from "openclaw/plugin-sdk/channel-inbound";
|
} from "openclaw/plugin-sdk/channel-inbound";
|
||||||
|
import { resolveChannelSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
||||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
||||||
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
|
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
|
||||||
@@ -524,12 +525,16 @@ export async function prepareSlackMessage(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { rawBody, effectiveDirectMedia } = resolvedMessageContent;
|
const { rawBody, effectiveDirectMedia } = resolvedMessageContent;
|
||||||
|
const chatType = resolveSlackChatType(conversation.resolvedChannelType);
|
||||||
|
|
||||||
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
const ackReaction = resolveAckReaction(cfg, route.agentId, {
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
const ackReactionValue = ackReaction ?? "";
|
const ackReactionValue = ackReaction ?? "";
|
||||||
|
const sourceRepliesAreToolOnly =
|
||||||
|
resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) ===
|
||||||
|
"message_tool_only";
|
||||||
|
|
||||||
const shouldAckReaction = () =>
|
const shouldAckReaction = () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -547,12 +552,13 @@ export async function prepareSlackMessage(params: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ackReactionMessageTs = message.ts;
|
const ackReactionMessageTs = message.ts;
|
||||||
|
const shouldSendAckReaction = !sourceRepliesAreToolOnly && shouldAckReaction();
|
||||||
const statusReactionsWillHandle =
|
const statusReactionsWillHandle =
|
||||||
Boolean(ackReactionMessageTs) &&
|
Boolean(ackReactionMessageTs) &&
|
||||||
cfg.messages?.statusReactions?.enabled !== false &&
|
cfg.messages?.statusReactions?.enabled !== false &&
|
||||||
shouldAckReaction();
|
shouldSendAckReaction;
|
||||||
const ackReactionPromise =
|
const ackReactionPromise =
|
||||||
!statusReactionsWillHandle && shouldAckReaction() && ackReactionMessageTs && ackReactionValue
|
!statusReactionsWillHandle && shouldSendAckReaction && ackReactionMessageTs && ackReactionValue
|
||||||
? reactSlackMessage(message.channel, ackReactionMessageTs, ackReactionValue, {
|
? reactSlackMessage(message.channel, ackReactionMessageTs, ackReactionValue, {
|
||||||
token: ctx.botToken,
|
token: ctx.botToken,
|
||||||
client: ctx.app.client,
|
client: ctx.app.client,
|
||||||
@@ -571,7 +577,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
|
|
||||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||||
const senderName = await resolveSenderName();
|
const senderName = await resolveSenderName();
|
||||||
const chatType = resolveSlackChatType(conversation.resolvedChannelType);
|
|
||||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||||
const inboundLabel = isDirectMessage
|
const inboundLabel = isDirectMessage
|
||||||
? `Slack DM from ${senderName}`
|
? `Slack DM from ${senderName}`
|
||||||
|
|||||||
@@ -1556,6 +1556,7 @@
|
|||||||
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
|
"test:voicecall:closedloop": "node scripts/test-voicecall-closedloop.mjs",
|
||||||
"test:watch": "node scripts/test-projects.mjs --watch",
|
"test:watch": "node scripts/test-projects.mjs --watch",
|
||||||
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/plugins/import-specifier.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
"test:windows:ci": "node scripts/test-projects.mjs src/shared/runtime-import.test.ts src/plugins/import-specifier.test.ts src/process/exec.windows.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts extensions/lobster/src/lobster-runner.test.ts test/scripts/npm-runner.test.ts test/scripts/pnpm-runner.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
|
||||||
|
"testbox:sanity": "node scripts/testbox-sync-sanity.mjs",
|
||||||
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
|
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
|
||||||
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
|
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
|
||||||
"ts-topology": "node --import tsx scripts/ts-topology.ts",
|
"ts-topology": "node --import tsx scripts/ts-topology.ts",
|
||||||
|
|||||||
@@ -250,17 +250,31 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
|||||||
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
|
["scripts/test-projects.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||||
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
|
["scripts/test-projects.test-support.d.mts", ["test/scripts/test-projects.test.ts"]],
|
||||||
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
|
["scripts/test-projects.test-support.mjs", ["test/scripts/test-projects.test.ts"]],
|
||||||
|
["scripts/testbox-sync-sanity.mjs", ["test/scripts/testbox-sync-sanity.test.ts"]],
|
||||||
]);
|
]);
|
||||||
const TOOLING_TEST_TARGETS = new Map([
|
const TOOLING_TEST_TARGETS = new Map([
|
||||||
["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]],
|
["test/scripts/barnacle-auto-response.test.ts", ["test/scripts/barnacle-auto-response.test.ts"]],
|
||||||
["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]],
|
["test/scripts/changed-lanes.test.ts", ["test/scripts/changed-lanes.test.ts"]],
|
||||||
["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]],
|
["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]],
|
||||||
["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]],
|
["test/scripts/test-projects.test.ts", ["test/scripts/test-projects.test.ts"]],
|
||||||
|
["test/scripts/testbox-sync-sanity.test.ts", ["test/scripts/testbox-sync-sanity.test.ts"]],
|
||||||
[
|
[
|
||||||
"test/scripts/vitest-local-scheduling.test.ts",
|
"test/scripts/vitest-local-scheduling.test.ts",
|
||||||
["test/scripts/vitest-local-scheduling.test.ts"],
|
["test/scripts/vitest-local-scheduling.test.ts"],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
const GROUP_VISIBLE_REPLY_TEST_TARGETS = [
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
|
];
|
||||||
|
const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [
|
||||||
|
"src/agents/system-prompt.test.ts",
|
||||||
|
...GROUP_VISIBLE_REPLY_TEST_TARGETS,
|
||||||
|
];
|
||||||
const SOURCE_TEST_TARGETS = new Map([
|
const SOURCE_TEST_TARGETS = new Map([
|
||||||
...PRECISE_SOURCE_TEST_TARGETS,
|
...PRECISE_SOURCE_TEST_TARGETS,
|
||||||
[
|
[
|
||||||
@@ -271,6 +285,11 @@ const SOURCE_TEST_TARGETS = new Map([
|
|||||||
"extensions/telegram/src/directory-contract.test.ts",
|
"extensions/telegram/src/directory-contract.test.ts",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/plugin-sdk/channel-reply-pipeline.ts",
|
||||||
|
["src/plugins/contracts/plugin-sdk-subpaths.test.ts", ...GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
|
],
|
||||||
|
["src/plugin-sdk/reply-runtime.ts", ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"]],
|
||||||
[
|
[
|
||||||
"test/helpers/channels/directory-ids.ts",
|
"test/helpers/channels/directory-ids.ts",
|
||||||
[
|
[
|
||||||
@@ -306,10 +325,8 @@ const SOURCE_TEST_TARGETS = new Map([
|
|||||||
"extensions/telegram/src/directory-contract.test.ts",
|
"extensions/telegram/src/directory-contract.test.ts",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
["src/auto-reply/reply/dispatch-from-config.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
"src/auto-reply/reply/dispatch-from-config.ts",
|
["src/auto-reply/reply/source-reply-delivery-mode.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
["src/auto-reply/reply/dispatch-from-config.test.ts"],
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"src/auto-reply/reply/effective-reply-route.ts",
|
"src/auto-reply/reply/effective-reply-route.ts",
|
||||||
[
|
[
|
||||||
@@ -317,6 +334,12 @@ const SOURCE_TEST_TARGETS = new Map([
|
|||||||
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
["src/auto-reply/reply/get-reply-run.ts", ["src/auto-reply/reply/followup-runner.test.ts"]],
|
||||||
|
["src/auto-reply/reply/groups.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
|
["src/auto-reply/get-reply-options.types.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
|
["src/agents/system-prompt.ts", GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS],
|
||||||
|
["src/config/types.messages.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
|
["src/config/zod-schema.core.ts", GROUP_VISIBLE_REPLY_TEST_TARGETS],
|
||||||
["src/auto-reply/reply/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
|
["src/auto-reply/reply/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
|
||||||
[
|
[
|
||||||
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",
|
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",
|
||||||
|
|||||||
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;
|
} as const satisfies OpenClawConfig;
|
||||||
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
||||||
|
let resolveSourceReplyDeliveryMode: typeof import("./source-reply-delivery-mode.js").resolveSourceReplyDeliveryMode;
|
||||||
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
||||||
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
|
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
|
||||||
type DispatchReplyArgs = Parameters<
|
type DispatchReplyArgs = Parameters<
|
||||||
@@ -498,6 +499,7 @@ type DispatchReplyArgs = Parameters<
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
|
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
|
||||||
|
({ resolveSourceReplyDeliveryMode } = await import("./source-reply-delivery-mode.js"));
|
||||||
await import("./dispatch-acp.js");
|
await import("./dispatch-acp.js");
|
||||||
await import("./dispatch-acp-command-bypass.js");
|
await import("./dispatch-acp-command-bypass.js");
|
||||||
await import("./dispatch-acp-tts.runtime.js");
|
await import("./dispatch-acp-tts.runtime.js");
|
||||||
@@ -3867,6 +3869,31 @@ describe("before_dispatch hook", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => {
|
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => {
|
||||||
|
it("resolves group source delivery from shared core config", () => {
|
||||||
|
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe(
|
||||||
|
"message_tool_only",
|
||||||
|
);
|
||||||
|
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "group" } })).toBe(
|
||||||
|
"message_tool_only",
|
||||||
|
);
|
||||||
|
expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe(
|
||||||
|
"automatic",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveSourceReplyDeliveryMode({
|
||||||
|
cfg: automaticGroupReplyConfig,
|
||||||
|
ctx: { ChatType: "group" },
|
||||||
|
}),
|
||||||
|
).toBe("automatic");
|
||||||
|
expect(
|
||||||
|
resolveSourceReplyDeliveryMode({
|
||||||
|
cfg: emptyConfig,
|
||||||
|
ctx: { ChatType: "channel" },
|
||||||
|
requested: "automatic",
|
||||||
|
}),
|
||||||
|
).toBe("automatic");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
sessionBindingMocks.resolveByConversation.mockReset();
|
sessionBindingMocks.resolveByConversation.mockReset();
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
|
|||||||
import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
|
import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js";
|
||||||
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
|
import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js";
|
||||||
import { resolveReplyRoutingDecision } from "./routing-policy.js";
|
import { resolveReplyRoutingDecision } from "./routing-policy.js";
|
||||||
|
import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js";
|
||||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||||
|
|
||||||
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
|
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
|
||||||
@@ -193,23 +194,6 @@ const resolveRoutedPolicyConversationType = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveSourceReplyDeliveryMode(params: {
|
|
||||||
cfg: OpenClawConfig;
|
|
||||||
ctx: FinalizedMsgContext;
|
|
||||||
requested?: "automatic" | "message_tool_only";
|
|
||||||
}): "automatic" | "message_tool_only" {
|
|
||||||
if (params.requested) {
|
|
||||||
return params.requested;
|
|
||||||
}
|
|
||||||
const chatType = normalizeChatType(params.ctx.ChatType);
|
|
||||||
if (chatType === "group" || chatType === "channel") {
|
|
||||||
return params.cfg.messages?.groupChat?.visibleReplies === "automatic"
|
|
||||||
? "automatic"
|
|
||||||
: "message_tool_only";
|
|
||||||
}
|
|
||||||
return "automatic";
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveSessionStoreLookup = (
|
const resolveSessionStoreLookup = (
|
||||||
ctx: FinalizedMsgContext,
|
ctx: FinalizedMsgContext,
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
|
|||||||
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 { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
createReplyPrefixContext,
|
||||||
@@ -10,12 +15,22 @@ import {
|
|||||||
type CreateTypingCallbacksParams,
|
type CreateTypingCallbacksParams,
|
||||||
type TypingCallbacks,
|
type TypingCallbacks,
|
||||||
} from "../channels/typing.js";
|
} from "../channels/typing.js";
|
||||||
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import type { ReplyPayload } from "./reply-payload.js";
|
import type { ReplyPayload } from "./reply-payload.js";
|
||||||
|
|
||||||
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
|
export type ReplyPrefixContext = ReplyPrefixContextBundle["prefixContext"];
|
||||||
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
|
export type { ReplyPrefixContextBundle, ReplyPrefixOptions };
|
||||||
export type { CreateTypingCallbacksParams, TypingCallbacks };
|
export type { CreateTypingCallbacksParams, TypingCallbacks };
|
||||||
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
|
export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks };
|
||||||
|
export type { SourceReplyDeliveryMode };
|
||||||
|
|
||||||
|
export function resolveChannelSourceReplyDeliveryMode(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
ctx: SourceReplyDeliveryModeContext;
|
||||||
|
requested?: SourceReplyDeliveryMode;
|
||||||
|
}): SourceReplyDeliveryMode {
|
||||||
|
return resolveSourceReplyDeliveryMode(params);
|
||||||
|
}
|
||||||
|
|
||||||
export type ChannelReplyPipeline = ReplyPrefixOptions & {
|
export type ChannelReplyPipeline = ReplyPrefixOptions & {
|
||||||
typingCallbacks?: TypingCallbacks;
|
typingCallbacks?: TypingCallbacks;
|
||||||
|
|||||||
@@ -1316,10 +1316,12 @@ describe("plugin-sdk subpath exports", () => {
|
|||||||
"createTypingCallbacks",
|
"createTypingCallbacks",
|
||||||
"createReplyPrefixContext",
|
"createReplyPrefixContext",
|
||||||
"createReplyPrefixOptions",
|
"createReplyPrefixOptions",
|
||||||
|
"resolveChannelSourceReplyDeliveryMode",
|
||||||
]);
|
]);
|
||||||
expect(typeof channelReplyPipelineSdk.createTypingCallbacks).toBe("function");
|
expect(typeof channelReplyPipelineSdk.createTypingCallbacks).toBe("function");
|
||||||
expect(typeof channelReplyPipelineSdk.createReplyPrefixContext).toBe("function");
|
expect(typeof channelReplyPipelineSdk.createReplyPrefixContext).toBe("function");
|
||||||
expect(typeof channelReplyPipelineSdk.createReplyPrefixOptions).toBe("function");
|
expect(typeof channelReplyPipelineSdk.createReplyPrefixOptions).toBe("function");
|
||||||
|
expect(typeof channelReplyPipelineSdk.resolveChannelSourceReplyDeliveryMode).toBe("function");
|
||||||
|
|
||||||
expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length);
|
expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length);
|
||||||
for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) {
|
for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) {
|
||||||
|
|||||||
@@ -141,6 +141,78 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes group visible reply config changes through channel delivery regressions", () => {
|
||||||
|
expect(
|
||||||
|
resolveChangedTestTargetPlan([
|
||||||
|
"src/config/types.messages.ts",
|
||||||
|
"src/config/zod-schema.core.ts",
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
mode: "targets",
|
||||||
|
targets: [
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes source reply prompt changes through prompt and channel delivery regressions", () => {
|
||||||
|
expect(resolveChangedTestTargetPlan(["src/agents/system-prompt.ts"])).toEqual({
|
||||||
|
mode: "targets",
|
||||||
|
targets: [
|
||||||
|
"src/agents/system-prompt.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes source reply delivery mode changes through channel delivery regressions", () => {
|
||||||
|
expect(
|
||||||
|
resolveChangedTestTargetPlan(["src/auto-reply/reply/source-reply-delivery-mode.ts"]),
|
||||||
|
).toEqual({
|
||||||
|
mode: "targets",
|
||||||
|
targets: [
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes channel reply pipeline SDK changes through SDK and channel delivery regressions", () => {
|
||||||
|
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/channel-reply-pipeline.ts"])).toEqual({
|
||||||
|
mode: "targets",
|
||||||
|
targets: [
|
||||||
|
"src/plugins/contracts/plugin-sdk-subpaths.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes reply runtime SDK exports through plugin SDK contract tests", () => {
|
||||||
|
expect(resolveChangedTestTargetPlan(["src/plugin-sdk/reply-runtime.ts"])).toEqual({
|
||||||
|
mode: "targets",
|
||||||
|
targets: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps extension batch runner edits on extension script tests", () => {
|
it("keeps extension batch runner edits on extension script tests", () => {
|
||||||
expect(resolveChangedTestTargetPlan(["scripts/test-extension-batch.mjs"])).toEqual({
|
expect(resolveChangedTestTargetPlan(["scripts/test-extension-batch.mjs"])).toEqual({
|
||||||
mode: "targets",
|
mode: "targets",
|
||||||
@@ -465,7 +537,12 @@ describe("scripts/test-projects changed-target routing", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
mode: "targets",
|
mode: "targets",
|
||||||
targets: [
|
targets: [
|
||||||
|
"src/auto-reply/reply/dispatch-acp.test.ts",
|
||||||
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
"src/auto-reply/reply/dispatch-from-config.test.ts",
|
||||||
|
"src/auto-reply/reply/followup-runner.test.ts",
|
||||||
|
"src/auto-reply/reply/groups.test.ts",
|
||||||
|
"extensions/discord/src/monitor/message-handler.process.test.ts",
|
||||||
|
"extensions/slack/src/monitor.tool-result.test.ts",
|
||||||
"src/auto-reply/reply/effective-reply-route.test.ts",
|
"src/auto-reply/reply/effective-reply-route.test.ts",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
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