Merge branch 'main' into feat/deepinfra-integration

This commit is contained in:
Georgi Atsev 2026-03-20 19:23:10 +02:00 committed by GitHub
commit 7a68f2bda3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 748 additions and 150 deletions

View File

@ -111,6 +111,7 @@
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
- Do not set test workers above 16; tried already.
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/help/testing.md`.

View File

@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.

View File

@ -1076,6 +1076,7 @@
"group": "Extensions",
"pages": [
"plugins/building-extensions",
"plugins/sdk-migration",
"plugins/architecture",
"plugins/community",
"plugins/bundles",

View File

@ -284,10 +284,12 @@ Azure Bastion Standard SKU runs approximately **\$140/month** and the VM (Standa
To reduce costs:
- **Deallocate the VM** when not in use (stops compute billing; disk charges remain). The OpenClaw Gateway will not be reachable while the VM is deallocated — restart it when you need it live again:
```bash
az vm deallocate -g "${RG}" -n "${VM_NAME}"
az vm start -g "${RG}" -n "${VM_NAME}" # restart later
```
- **Delete Bastion when not needed** and recreate it when you need SSH access. Bastion is the largest cost component and takes only a few minutes to provision.
- **Use the Basic Bastion SKU** (~\$38/month) if you only need Portal-based SSH and don't require CLI tunneling (`az network bastion ssh`).

View File

@ -0,0 +1,144 @@
---
title: "Plugin SDK Migration"
summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports"
read_when:
- You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning
- You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths
- You maintain an external OpenClaw plugin
---
# Plugin SDK Migration
OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel
to **focused subpath imports** (`openclaw/plugin-sdk/<subpath>`). This page explains
what changed, why, and how to migrate.
## Why this change
The monolithic compat barrel re-exported everything from a single entry point.
This caused:
- **Slow startup**: importing one helper pulled in dozens of unrelated modules.
- **Circular dependency risk**: broad re-exports made it easy to create import cycles.
- **Unclear API surface**: no way to tell which exports were stable vs internal.
Focused subpaths fix all three: each subpath is a small, self-contained module
with a clear purpose.
## What triggers the warning
If your plugin imports from the compat barrel, you will see:
```
[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is
deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports.
```
The compat barrel still works at runtime. This is a deprecation warning, not an
error. But new plugins **must not** use it, and existing plugins should migrate
before compat is removed.
## How to migrate
### Step 1: Find compat imports
Search your extension for imports from the compat path:
```bash
grep -r "plugin-sdk/compat" extensions/my-plugin/
```
### Step 2: Replace with focused subpaths
Each export from compat maps to a specific subpath. Replace the import source:
```typescript
// Before (compat barrel)
import {
createChannelReplyPipeline,
createPluginRuntimeStore,
resolveControlCommandGate,
} from "openclaw/plugin-sdk/compat";
// After (focused subpaths)
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
```
### Step 3: Verify
Run the build and tests:
```bash
pnpm build
pnpm test -- extensions/my-plugin/
```
## Subpath reference
| Subpath | Purpose | Key exports |
| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
| `plugin-sdk/channel-send-result` | Send result types | Reply result types |
| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` |
| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` |
| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` |
| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers |
| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities |
| `plugin-sdk/reply-payload` | Message reply types | Reply payload types |
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
| `plugin-sdk/testing` | Test utilities | Test helpers and mocks |
Use the narrowest subpath that has what you need. If you cannot find an export,
check the source at `src/plugin-sdk/` or ask in Discord.
## Compat barrel removal timeline
- **Now**: compat barrel emits a deprecation warning at runtime.
- **Next major release**: compat barrel will be removed. Plugins still using it will
fail to import.
Bundled plugins (under `extensions/`) have already been migrated. External plugins
should migrate before the next major release.
## Suppressing the warning temporarily
If you need to suppress the warning while migrating:
```bash
OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run
```
This is a temporary escape hatch, not a permanent solution.
## Internal barrel pattern
Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for
internal code sharing instead of importing through the plugin SDK:
```typescript
// extensions/my-plugin/api.ts — public contract for this extension
export { MyConfig } from "./src/config.js";
export { MyRuntime } from "./src/runtime.js";
```
Never import your own extension back through `openclaw/plugin-sdk/<your-extension>`
from production files. That path is for external consumers only. See
[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports).
## Related
- [Building Extensions](/plugins/building-extensions)
- [Plugin Architecture](/plugins/architecture)
- [Plugin Manifest](/plugins/manifest)

View File

@ -11,8 +11,9 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for local runs with enough memory. CI stays on `forks` unless explicitly overridden. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
- Files marked `singletonIsolated` no longer spawn one fresh Vitest process each by default. The wrapper batches them into dedicated `forks` lanes with `maxWorkers=1`, which preserves isolation from `unit-fast` while cutting process startup overhead. Tune lane count with `OPENCLAW_TEST_SINGLETON_ISOLATED_LANES=<n>`.
- `pnpm test:channels`: runs channel-heavy suites.
- `pnpm test:extensions`: runs extension/plugin suites.
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/feishu.js";
export * from "openclaw/plugin-sdk/feishu";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Google Chat extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/googlechat.js";
export * from "openclaw/plugin-sdk/googlechat";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled IRC extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../../src/plugin-sdk/irc.js";
export * from "openclaw/plugin-sdk/irc";

View File

@ -1,12 +1,12 @@
// Private runtime barrel for the bundled LINE extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/line.js";
export { resolveExactLineGroupConfigKey } from "../../src/plugin-sdk/line-core.js";
export * from "openclaw/plugin-sdk/line";
export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core";
export {
formatDocsLink,
setSetupChannelEnabled,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../../src/plugin-sdk/line-core.js";
} from "openclaw/plugin-sdk/line-core";

View File

@ -5,7 +5,7 @@ import {
resolveLineAccount,
type OpenClawConfig,
type ResolvedLineAccount,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/line-core";
export function normalizeLineAllowFrom(entry: string): string {
return entry.replace(/^line:(?:user:)?/i, "");

View File

@ -1,5 +1,5 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js";
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core";
type LineGroupContext = {
cfg: OpenClawConfig;

View File

@ -1,11 +1,11 @@
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
listLineAccountIds,
normalizeAccountId,
resolveLineAccount,
type LineConfig,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/line-core";
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
const channel = "line" as const;

View File

@ -1,4 +1,3 @@
import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@ -7,7 +6,8 @@ import {
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/line-core";
import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
import {
isLineConfigured,
listLineAccountIds,

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Mattermost extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/mattermost.js";
export * from "openclaw/plugin-sdk/mattermost";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Microsoft Teams extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/msteams.js";
export * from "openclaw/plugin-sdk/msteams";

View File

@ -141,7 +141,7 @@ describe("resolveGraphChatId", () => {
}),
);
// Should filter by user AAD object ID
const callUrl = (fetchFn.mock.calls[0] as [string, unknown])[0];
const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0];
expect(callUrl).toContain("user-aad-object-id-123");
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
});

View File

@ -50,9 +50,14 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
},
});
const noopUpdateActivity = async () => {};
const noopDeleteActivity = async () => {};
const createNoopAdapter = (): MSTeamsAdapter => ({
continueConversation: async () => {},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
const createRecordedSendActivity = (
@ -81,6 +86,8 @@ const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
describe("msteams messenger", () => {
@ -195,6 +202,8 @@ describe("msteams messenger", () => {
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const ids = await sendMSTeamsMessages({
@ -366,6 +375,8 @@ describe("msteams messenger", () => {
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
},
process: async () => {},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const ids = await sendMSTeamsMessages({

View File

@ -42,6 +42,8 @@ function createDeps(): MSTeamsMessageHandlerDeps {
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
process: async () => {},
updateActivity: async () => {},
deleteActivity: async () => {},
};
const conversationStore: MSTeamsConversationStore = {
upsert: async () => {},
@ -82,6 +84,8 @@ function createActivityHandler(): MSTeamsActivityHandler {
handler = {
onMessage: () => handler,
onMembersAdded: () => handler,
onReactionsAdded: () => handler,
onReactionsRemoved: () => handler,
run: async () => {},
};
return handler;

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Nextcloud Talk extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/nextcloud-talk.js";
export * from "openclaw/plugin-sdk/nextcloud-talk";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Nostr extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/nostr.js";
export * from "openclaw/plugin-sdk/nostr";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Signal extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../../src/plugin-sdk/signal.js";
export * from "openclaw/plugin-sdk/signal";

View File

@ -11,6 +11,7 @@ import {
const createTelegramDraftStream = vi.hoisted(() => vi.fn());
const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn());
const deliverReplies = vi.hoisted(() => vi.fn());
const emitInternalMessageSentHook = vi.hoisted(() => vi.fn());
const createForumTopicTelegram = vi.hoisted(() => vi.fn());
const deleteMessageTelegram = vi.hoisted(() => vi.fn());
const editForumTopicTelegram = vi.hoisted(() => vi.fn());
@ -46,6 +47,7 @@ vi.mock("./draft-stream.js", () => ({
vi.mock("./bot/delivery.js", () => ({
deliverReplies,
emitInternalMessageSentHook,
}));
vi.mock("./send.js", () => ({
@ -103,6 +105,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
createTelegramDraftStream.mockClear();
dispatchReplyWithBufferedBlockDispatcher.mockClear();
deliverReplies.mockClear();
emitInternalMessageSentHook.mockClear();
createForumTopicTelegram.mockClear();
deleteMessageTelegram.mockClear();
editForumTopicTelegram.mockClear();
@ -521,6 +524,38 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.stop).toHaveBeenCalled();
});
it("emits only the internal message:sent hook when a final answer stays in preview", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" });
return { queuedFinal: true };
});
await dispatchWithContext({
context: createContext({
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
}),
});
expect(deliverReplies).not.toHaveBeenCalled();
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
999,
"Primary result",
expect.any(Object),
);
expect(emitInternalMessageSentHook).toHaveBeenCalledWith(
expect.objectContaining({
sessionKeyForInternalHooks: "s1",
chatId: "123",
content: "Primary result",
success: true,
messageId: 999,
}),
);
});
it("keeps streamed preview visible when final text regresses after a tool warning", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@ -30,7 +30,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import type { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { createTelegramDraftStream } from "./draft-stream.js";
@ -41,6 +41,7 @@ import {
createLaneDeliveryStateTracker,
createLaneTextDeliverer,
type DraftLaneState,
type LaneDeliveryResult,
type LaneName,
type LanePreviewLifecycle,
} from "./lane-delivery.js";
@ -480,6 +481,21 @@ export const dispatchTelegramMessage = async ({
}
return result.delivered;
};
const emitPreviewFinalizedHook = (result: LaneDeliveryResult) => {
if (result.kind !== "preview-finalized") {
return;
}
emitInternalMessageSentHook({
sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks,
chatId: deliveryBaseOptions.chatId,
accountId: deliveryBaseOptions.accountId,
content: result.delivery.content,
success: true,
messageId: result.delivery.messageId,
isGroup: deliveryBaseOptions.mirrorIsGroup,
groupId: deliveryBaseOptions.mirrorGroupId,
});
};
const deliverLaneText = createLaneTextDeliverer({
lanes,
archivedAnswerPreviews,
@ -612,8 +628,11 @@ export const dispatchTelegramMessage = async ({
previewButtons,
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
});
if (info.kind === "final") {
emitPreviewFinalizedHook(result);
}
if (segment.lane === "reasoning") {
if (result !== "skipped") {
if (result.kind !== "skipped") {
reasoningStepState.noteReasoningDelivered();
await flushBufferedFinalAnswer();
}

View File

@ -491,9 +491,7 @@ async function maybePinFirstDeliveredMessage(params: {
}
}
function emitMessageSentHooks(params: {
hookRunner: ReturnType<typeof getGlobalHookRunner>;
enabled: boolean;
type EmitMessageSentHookParams = {
sessionKeyForInternalHooks?: string;
chatId: string;
accountId?: string;
@ -503,11 +501,10 @@ function emitMessageSentHooks(params: {
messageId?: number;
isGroup?: boolean;
groupId?: string;
}): void {
if (!params.enabled && !params.sessionKeyForInternalHooks) {
return;
}
const canonical = buildCanonicalSentMessageHookContext({
};
function buildTelegramSentHookContext(params: EmitMessageSentHookParams) {
return buildCanonicalSentMessageHookContext({
to: params.chatId,
content: params.content,
success: params.success,
@ -519,20 +516,13 @@ function emitMessageSentHooks(params: {
isGroup: params.isGroup,
groupId: params.groupId,
});
if (params.enabled) {
fireAndForgetHook(
Promise.resolve(
params.hookRunner!.runMessageSent(
toPluginMessageSentEvent(canonical),
toPluginMessageContext(canonical),
),
),
"telegram: message_sent plugin hook failed",
);
}
}
export function emitInternalMessageSentHook(params: EmitMessageSentHookParams): void {
if (!params.sessionKeyForInternalHooks) {
return;
}
const canonical = buildTelegramSentHookContext(params);
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent(
@ -546,6 +536,30 @@ function emitMessageSentHooks(params: {
);
}
function emitMessageSentHooks(
params: EmitMessageSentHookParams & {
hookRunner: ReturnType<typeof getGlobalHookRunner>;
enabled: boolean;
},
): void {
if (!params.enabled && !params.sessionKeyForInternalHooks) {
return;
}
const canonical = buildTelegramSentHookContext(params);
if (params.enabled) {
fireAndForgetHook(
Promise.resolve(
params.hookRunner!.runMessageSent(
toPluginMessageSentEvent(canonical),
toPluginMessageContext(canonical),
),
),
"telegram: message_sent plugin hook failed",
);
}
emitInternalMessageSentHook(params);
}
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;

View File

@ -1,2 +1,2 @@
export { deliverReplies } from "./delivery.replies.js";
export { deliverReplies, emitInternalMessageSentHook } from "./delivery.replies.js";
export { resolveMedia } from "./delivery.resolve-media.js";

View File

@ -57,11 +57,14 @@ export type ArchivedPreview = {
export type LanePreviewLifecycle = "transient" | "complete";
export type LaneDeliveryResult =
| "preview-finalized"
| "preview-retained"
| "preview-updated"
| "sent"
| "skipped";
| {
kind: "preview-finalized";
delivery: {
content: string;
messageId?: number;
};
}
| { kind: "preview-retained" | "preview-updated" | "sent" | "skipped" };
type CreateLaneTextDelivererParams = {
lanes: Record<LaneName, DraftLaneState>;
@ -107,7 +110,7 @@ type TryUpdatePreviewParams = {
previewTextSnapshot?: string;
};
type PreviewEditResult = "edited" | "retained" | "fallback";
type PreviewEditResult = "edited" | "retained" | "regressive-skipped" | "fallback";
type ConsumeArchivedAnswerPreviewParams = {
lane: DraftLaneState;
@ -133,6 +136,16 @@ type PreviewTargetResolution = {
stopCreatesFirstPreview: boolean;
};
function result(
kind: LaneDeliveryResult["kind"],
delivery?: Extract<LaneDeliveryResult, { kind: "preview-finalized" }>["delivery"],
): LaneDeliveryResult {
if (kind === "preview-finalized") {
return { kind, delivery: delivery! };
}
return { kind };
}
function shouldSkipRegressivePreviewUpdate(args: {
currentPreviewText: string | undefined;
text: string;
@ -189,10 +202,10 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
lane: DraftLaneState;
laneName: LaneName;
text: string;
}): Promise<boolean> => {
}): Promise<number | undefined> => {
const stream = args.lane.stream;
if (!stream || !isDraftPreviewLane(args.lane)) {
return false;
return undefined;
}
// Draft previews have no message_id to edit; materialize the final text
// into a real message and treat that as the finalized delivery.
@ -202,11 +215,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
params.log(
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
);
return false;
return undefined;
}
args.lane.lastPartialText = args.text;
params.markDelivered();
return true;
return materializedMessageId;
};
const tryEditPreviewMessage = async (args: {
@ -338,7 +351,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
});
if (shouldSkipRegressive) {
params.markDelivered();
return "edited";
return "regressive-skipped";
}
return editPreview(
previewMessageId,
@ -427,11 +440,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
previewTextSnapshot: archivedPreview.textSnapshot,
});
if (finalized === "edited") {
return "preview-finalized";
return result("preview-finalized", {
content: text,
messageId: archivedPreview.messageId,
});
}
if (finalized === "regressive-skipped") {
return result("preview-finalized", {
content: archivedPreview.textSnapshot,
messageId: archivedPreview.messageId,
});
}
if (finalized === "retained") {
params.retainPreviewOnCleanupByLane.answer = true;
return "preview-retained";
return result("preview-retained");
}
}
// Send the replacement message first, then clean up the old preview.
@ -448,7 +470,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
);
}
}
return delivered ? "sent" : "skipped";
return delivered ? result("sent") : result("skipped");
};
return async ({
@ -499,16 +521,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
}
if (canMaterializeDraftFinal(lane, previewButtons)) {
const materialized = await tryMaterializeDraftPreviewForFinal({
const materializedMessageId = await tryMaterializeDraftPreviewForFinal({
lane,
laneName,
text,
});
if (materialized) {
if (typeof materializedMessageId === "number") {
markActivePreviewComplete(laneName);
return "preview-finalized";
return result("preview-finalized", {
content: text,
messageId: materializedMessageId,
});
}
}
const previewMessageId = lane.stream?.messageId();
const finalized = await tryUpdatePreviewForLane({
lane,
laneName,
@ -520,11 +546,21 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
});
if (finalized === "edited") {
markActivePreviewComplete(laneName);
return "preview-finalized";
return result("preview-finalized", {
content: text,
messageId: previewMessageId ?? lane.stream?.messageId(),
});
}
if (finalized === "regressive-skipped") {
markActivePreviewComplete(laneName);
return result("preview-finalized", {
content: lane.lastPartialText,
messageId: previewMessageId ?? lane.stream?.messageId(),
});
}
if (finalized === "retained") {
markActivePreviewComplete(laneName);
return "preview-retained";
return result("preview-retained");
}
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
params.log(
@ -533,7 +569,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
}
await params.stopDraftLane(lane);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
return delivered ? result("sent") : result("skipped");
}
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
@ -549,11 +585,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
);
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
return delivered ? result("sent") : result("skipped");
}
lane.lastPartialText = text;
params.markDelivered();
return "preview-updated";
return result("preview-updated");
}
const updated = await tryUpdatePreviewForLane({
lane,
@ -565,12 +601,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
skipRegressive: "always",
context: "update",
});
if (updated === "edited") {
return "preview-updated";
if (updated === "edited" || updated === "regressive-skipped") {
return result("preview-updated");
}
}
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
return delivered ? "sent" : "skipped";
return delivered ? result("sent") : result("skipped");
};
}

View File

@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { createTestDraftStream } from "./draft-stream.test-helpers.js";
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";
import {
createLaneTextDeliverer,
type DraftLaneState,
type LaneDeliveryResult,
type LaneName,
} from "./lane-delivery.js";
const HELLO_FINAL = "Hello final";
@ -101,7 +106,7 @@ async function expectFinalPreviewRetained(params: {
expectedLogSnippet?: string;
}) {
const result = await deliverFinalAnswer(params.harness, params.text ?? HELLO_FINAL);
expect(result).toBe("preview-retained");
expect(result.kind).toBe("preview-retained");
expect(params.harness.sendPayload).not.toHaveBeenCalled();
if (params.expectedLogSnippet) {
expect(params.harness.log).toHaveBeenCalledWith(
@ -124,7 +129,7 @@ async function expectFinalEditFallbackToSend(params: {
expectedLogSnippet: string;
}) {
const result = await deliverFinalAnswer(params.harness, params.text);
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(params.harness.editPreview).toHaveBeenCalledTimes(1);
expect(params.harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: params.text }),
@ -134,13 +139,23 @@ async function expectFinalEditFallbackToSend(params: {
);
}
function expectPreviewFinalized(
result: LaneDeliveryResult,
): Extract<LaneDeliveryResult, { kind: "preview-finalized" }>["delivery"] {
expect(result.kind).toBe("preview-finalized");
if (result.kind !== "preview-finalized") {
throw new Error(`expected preview-finalized, got ${result.kind}`);
}
return result.delivery;
}
describe("createLaneTextDeliverer", () => {
it("finalizes text-only replies by editing an existing preview message", async () => {
const harness = createHarness({ answerMessageId: 999 });
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
laneName: "answer",
@ -164,7 +179,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({ content: "no problem", messageId: 777 });
expect(harness.answer.stream?.update).toHaveBeenCalledWith("no problem");
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
@ -187,7 +202,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("preview-retained");
expect(result.kind).toBe("preview-retained");
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.log).toHaveBeenCalledWith(
@ -205,7 +220,7 @@ describe("createLaneTextDeliverer", () => {
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
@ -244,7 +259,7 @@ describe("createLaneTextDeliverer", () => {
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: HELLO_FINAL }),
);
@ -273,7 +288,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Short final" }),
@ -291,7 +306,10 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({
content: "Recovered final answer.",
messageId: 999,
});
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
@ -308,7 +326,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText }));
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
@ -331,7 +349,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({ content: "Hello final", messageId: 321 });
expect(harness.flushDraftLane).toHaveBeenCalled();
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
@ -360,7 +378,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(expectPreviewFinalized(result)).toEqual({ content: "Final answer", messageId: 654 });
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
@ -377,7 +395,7 @@ describe("createLaneTextDeliverer", () => {
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: HELLO_FINAL }),
@ -402,7 +420,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Image incoming", mediaUrl: "file:///tmp/example.png" }),
);
@ -425,7 +443,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Choose one" }),
);
@ -456,7 +474,7 @@ describe("createLaneTextDeliverer", () => {
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Complete final answer" }),
);
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555);
});
@ -469,12 +487,30 @@ describe("createLaneTextDeliverer", () => {
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(result).toBe("preview-retained");
expect(result.kind).toBe("preview-retained");
expect(harness.log).toHaveBeenCalledWith(
expect.stringContaining("edit target missing; keeping alternate preview without fallback"),
);
});
it("keeps the archived preview when the final text regresses", async () => {
const harness = createHarness();
harness.archivedAnswerPreviews.push({
messageId: 5555,
textSnapshot: "Recovered final answer.",
deleteIfUnused: true,
});
const result = await deliverFinalAnswer(harness, "Recovered final answer");
expect(expectPreviewFinalized(result)).toEqual({
content: "Recovered final answer.",
messageId: 5555,
});
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).not.toHaveBeenCalled();
});
it("falls back on 4xx client rejection with error_code during final", async () => {
const harness = createHarness({ answerMessageId: 999 });
const err = Object.assign(new Error("403: Forbidden"), { error_code: 403 });
@ -505,7 +541,7 @@ describe("createLaneTextDeliverer", () => {
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: HELLO_FINAL }),
);
@ -546,7 +582,7 @@ describe("createLaneTextDeliverer", () => {
infoKind: "final",
});
expect(result).toBe("sent");
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Final with media", mediaUrl: "file:///tmp/example.png" }),
);

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Tlon extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/tlon.js";
export * from "openclaw/plugin-sdk/tlon";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Twitch extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/twitch.js";
export * from "openclaw/plugin-sdk/twitch";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Voice Call extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/voice-call.js";
export * from "openclaw/plugin-sdk/voice-call";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Zalo extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/zalo.js";
export * from "openclaw/plugin-sdk/zalo";

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Zalo Personal extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "../../src/plugin-sdk/zalouser.js";
export * from "openclaw/plugin-sdk/zalouser";

View File

@ -193,10 +193,50 @@
"types": "./dist/plugin-sdk/discord-core.d.ts",
"default": "./dist/plugin-sdk/discord-core.js"
},
"./plugin-sdk/feishu": {
"types": "./dist/plugin-sdk/feishu.d.ts",
"default": "./dist/plugin-sdk/feishu.js"
},
"./plugin-sdk/googlechat": {
"types": "./dist/plugin-sdk/googlechat.d.ts",
"default": "./dist/plugin-sdk/googlechat.js"
},
"./plugin-sdk/irc": {
"types": "./dist/plugin-sdk/irc.d.ts",
"default": "./dist/plugin-sdk/irc.js"
},
"./plugin-sdk/line": {
"types": "./dist/plugin-sdk/line.d.ts",
"default": "./dist/plugin-sdk/line.js"
},
"./plugin-sdk/line-core": {
"types": "./dist/plugin-sdk/line-core.d.ts",
"default": "./dist/plugin-sdk/line-core.js"
},
"./plugin-sdk/matrix": {
"types": "./dist/plugin-sdk/matrix.d.ts",
"default": "./dist/plugin-sdk/matrix.js"
},
"./plugin-sdk/mattermost": {
"types": "./dist/plugin-sdk/mattermost.d.ts",
"default": "./dist/plugin-sdk/mattermost.js"
},
"./plugin-sdk/msteams": {
"types": "./dist/plugin-sdk/msteams.d.ts",
"default": "./dist/plugin-sdk/msteams.js"
},
"./plugin-sdk/nextcloud-talk": {
"types": "./dist/plugin-sdk/nextcloud-talk.d.ts",
"default": "./dist/plugin-sdk/nextcloud-talk.js"
},
"./plugin-sdk/nostr": {
"types": "./dist/plugin-sdk/nostr.d.ts",
"default": "./dist/plugin-sdk/nostr.js"
},
"./plugin-sdk/signal": {
"types": "./dist/plugin-sdk/signal.d.ts",
"default": "./dist/plugin-sdk/signal.js"
},
"./plugin-sdk/slack": {
"types": "./dist/plugin-sdk/slack.d.ts",
"default": "./dist/plugin-sdk/slack.js"
@ -205,6 +245,26 @@
"types": "./dist/plugin-sdk/slack-core.d.ts",
"default": "./dist/plugin-sdk/slack-core.js"
},
"./plugin-sdk/tlon": {
"types": "./dist/plugin-sdk/tlon.d.ts",
"default": "./dist/plugin-sdk/tlon.js"
},
"./plugin-sdk/twitch": {
"types": "./dist/plugin-sdk/twitch.d.ts",
"default": "./dist/plugin-sdk/twitch.js"
},
"./plugin-sdk/voice-call": {
"types": "./dist/plugin-sdk/voice-call.d.ts",
"default": "./dist/plugin-sdk/voice-call.js"
},
"./plugin-sdk/zalo": {
"types": "./dist/plugin-sdk/zalo.d.ts",
"default": "./dist/plugin-sdk/zalo.js"
},
"./plugin-sdk/zalouser": {
"types": "./dist/plugin-sdk/zalouser.d.ts",
"default": "./dist/plugin-sdk/zalouser.js"
},
"./plugin-sdk/imessage": {
"types": "./dist/plugin-sdk/imessage.d.ts",
"default": "./dist/plugin-sdk/imessage.js"

View File

@ -38,9 +38,24 @@
"telegram-core",
"discord",
"discord-core",
"feishu",
"googlechat",
"irc",
"line",
"line-core",
"matrix",
"mattermost",
"msteams",
"nextcloud-talk",
"nostr",
"signal",
"slack",
"slack-core",
"tlon",
"twitch",
"voice-call",
"zalo",
"zalouser",
"imessage",
"imessage-core",
"whatsapp",

View File

@ -15,6 +15,7 @@ import {
resolveTestRunExitCode,
} from "./test-parallel-utils.mjs";
import {
dedupeFilesPreserveOrder,
loadUnitMemoryHotspotManifest,
loadTestRunnerBehavior,
loadUnitTimingManifest,
@ -81,18 +82,18 @@ const testProfile =
? rawTestProfile
: "normal";
const isMacMiniProfile = testProfile === "macmini";
// vmForks is a big win for transform/import heavy suites. Node 24 is stable again
// for the default unit-fast lane after moving the known flaky files to fork-only
// isolation, but Node 25+ still falls back to process forks until re-validated.
// Keep it opt-out via OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1.
// Vitest executes Node tests through Vite's SSR/module-runner pipeline, so the
// shared unit lane still retains transformed ESM/module state even when the
// tests themselves are not "server rendering" a website. vmForks can win in
// ideal transform-heavy cases, but for this repo we measured higher aggregate
// CPU load and fatal heap OOMs on memory-constrained dev machines and CI when
// unit-fast stayed on vmForks. Keep forks as the default unless that evidence
// is re-run and replaced:
// PR: https://github.com/openclaw/openclaw/pull/51145
// OOM evidence: https://github.com/openclaw/openclaw/pull/51145#issuecomment-4099663958
// Preserve OPENCLAW_TEST_VM_FORKS=1 as the explicit override/debug escape hatch.
const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true;
const useVmForks =
process.env.OPENCLAW_TEST_VM_FORKS === "1" ||
(process.env.OPENCLAW_TEST_VM_FORKS !== "0" &&
!isWindows &&
supportsVmForks &&
!lowMemLocalHost &&
(isCI || testProfile !== "low"));
const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" && supportsVmForks;
const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1";
const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1";
const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1";
@ -345,15 +346,46 @@ const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitF
memoryHeavyFiles: [],
timedHeavyFiles: [],
};
const unitSingletonBatchFiles = dedupeFilesPreserveOrder(
unitSingletonIsolatedFiles,
new Set(unitBehaviorIsolatedFiles),
);
const unitMemorySingletonFiles = dedupeFilesPreserveOrder(
memoryHeavyUnitFiles,
new Set([...unitBehaviorOverrideSet, ...unitSingletonBatchFiles]),
);
const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]);
const unitFastExcludedFiles = [
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
];
const unitAutoSingletonFiles = [
...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]),
];
const defaultSingletonBatchLaneCount =
testProfile === "serial"
? 0
: unitSingletonBatchFiles.length === 0
? 0
: isCI
? Math.ceil(unitSingletonBatchFiles.length / 6)
: highMemLocalHost
? Math.ceil(unitSingletonBatchFiles.length / 8)
: lowMemLocalHost
? Math.ceil(unitSingletonBatchFiles.length / 12)
: Math.ceil(unitSingletonBatchFiles.length / 10);
const singletonBatchLaneCount =
unitSingletonBatchFiles.length === 0
? 0
: Math.min(
unitSingletonBatchFiles.length,
Math.max(
1,
parseEnvNumber("OPENCLAW_TEST_SINGLETON_ISOLATED_LANES", defaultSingletonBatchLaneCount),
),
);
const estimateUnitDurationMs = (file) =>
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
const unitSingletonBuckets =
singletonBatchLaneCount > 0
? packFilesByDuration(unitSingletonBatchFiles, singletonBatchLaneCount, estimateUnitDurationMs)
: [];
const unitFastExcludedFileSet = new Set(unitFastExcludedFiles);
const unitFastCandidateFiles = allKnownUnitFiles.filter(
(file) => !unitFastExcludedFileSet.has(file),
@ -400,6 +432,11 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({
name: `unit-heavy-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({
name:
unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
const baseRuns = [
...(shouldSplitUnitRuns
? [
@ -420,7 +457,8 @@ const baseRuns = [
]
: []),
...unitHeavyEntries,
...unitAutoSingletonFiles.map((file) => ({
...unitSingletonEntries,
...unitMemorySingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-isolated`,
args: [
"vitest",
@ -756,6 +794,9 @@ const maxWorkersForRun = (name) => {
if (resolvedOverride) {
return resolvedOverride;
}
if (name === "unit-singleton" || name.startsWith("unit-singleton-")) {
return 1;
}
if (isCI && !isMacOS) {
return null;
}

View File

@ -231,3 +231,18 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
}
export function dedupeFilesPreserveOrder(files, exclude = new Set()) {
const result = [];
const seen = new Set();
for (const file of files) {
if (exclude.has(file) || seen.has(file)) {
continue;
}
seen.add(file);
result.push(file);
}
return result;
}

View File

@ -76,6 +76,33 @@ describe("getSubagentDepthFromSessionStore", () => {
expect(depth).toBe(2);
});
it("accepts JSON5 syntax in the on-disk depth store for backward compatibility", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-json5-"));
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const storePath = storeTemplate.replaceAll("{agentId}", "main");
fs.writeFileSync(
storePath,
`{
// hand-edited legacy store
"agent:main:subagent:flat": {
sessionId: "subagent-flat",
spawnDepth: 2,
},
}`,
"utf-8",
);
const depth = getSubagentDepthFromSessionStore("subagent:flat", {
cfg: {
session: {
store: storeTemplate,
},
},
});
expect(depth).toBe(2);
});
it("falls back to session-key segment counting when metadata is missing", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {

View File

@ -1,8 +1,8 @@
import fs from "node:fs";
import JSON5 from "json5";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { resolveDefaultAgentId } from "./agent-scope.js";
type SessionDepthEntry = {
@ -37,7 +37,7 @@ function normalizeSessionKey(value: unknown): string | undefined {
function readSessionStore(storePath: string): Record<string, SessionDepthEntry> {
try {
const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw);
const parsed = parseJsonWithJson5Fallback(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, SessionDepthEntry>;
}

View File

@ -442,6 +442,15 @@ describe("config cli", () => {
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("rejects JSON5-only object syntax when strict parsing is enabled", async () => {
await expect(
runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("accepts --strict-json with batch mode and applies batch payload", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
@ -470,6 +479,8 @@ describe("config cli", () => {
expect(helpText).toContain("--strict-json");
expect(helpText).toContain("--json");
expect(helpText).toContain("Legacy alias for --strict-json");
expect(helpText).toContain("Value (JSON/JSON5 or raw string)");
expect(helpText).toContain("Strict JSON parsing (error instead of");
expect(helpText).toContain("--ref-provider");
expect(helpText).toContain("--provider-source");
expect(helpText).toContain("--batch-json");

View File

@ -159,9 +159,9 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
const trimmed = raw.trim();
if (opts.strictJson) {
try {
return JSON5.parse(trimmed);
return JSON.parse(trimmed);
} catch (err) {
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err });
}
}
@ -1280,8 +1280,8 @@ export function registerConfigCli(program: Command) {
.command("set")
.description(CONFIG_SET_DESCRIPTION)
.argument("[path]", "Config path (dot or bracket notation)")
.argument("[value]", "Value (JSON5 or raw string)")
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
.argument("[value]", "Value (JSON/JSON5 or raw string)")
.option("--strict-json", "Strict JSON parsing (error instead of raw string fallback)", false)
.option("--json", "Legacy alias for --strict-json", false)
.option(
"--dry-run",

View File

@ -99,7 +99,7 @@ function resolveUserPath(
export const STATE_DIR = resolveStateDir();
/**
* Config file path (JSON5).
* Config file path (JSON or JSON5).
* Can be overridden via OPENCLAW_CONFIG_PATH.
* Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json)
*/

View File

@ -56,6 +56,38 @@ describe("cron store", () => {
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
});
it("accepts JSON5 syntax when loading an existing cron store", async () => {
const store = await makeStorePath();
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
`{
// hand-edited legacy store
version: 1,
jobs: [
{
id: 'job-1',
name: 'Job 1',
enabled: true,
createdAtMs: 1,
updatedAtMs: 1,
schedule: { kind: 'every', everyMs: 60000 },
sessionTarget: 'main',
wakeMode: 'next-heartbeat',
payload: { kind: 'systemEvent', text: 'tick-job-1' },
state: {},
},
],
}`,
"utf-8",
);
await expect(loadCronStore(store.storePath)).resolves.toMatchObject({
version: 1,
jobs: [{ id: "job-1", enabled: true }],
});
});
it("does not create a backup file when saving unchanged content", async () => {
const store = await makeStorePath();
const payload = makeStore("job-1", true);

View File

@ -1,9 +1,9 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { expandHomePrefix } from "../infra/home-dir.js";
import { CONFIG_DIR } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import type { CronStoreFile } from "./types.js";
export const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron");
@ -26,7 +26,7 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
const raw = await fs.promises.readFile(storePath, "utf-8");
let parsed: unknown;
try {
parsed = JSON5.parse(raw);
parsed = parseJsonWithJson5Fallback(raw);
} catch (err) {
throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, {
cause: err,

View File

@ -11,6 +11,7 @@ import { extractArchive, resolvePackedRootDir } from "./archive.js";
let fixtureRoot = "";
let fixtureCount = 0;
const directorySymlinkType = process.platform === "win32" ? "junction" : undefined;
const ARCHIVE_EXTRACT_TIMEOUT_MS = 15_000;
async function makeTempDir(prefix = "case") {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
@ -67,7 +68,7 @@ async function expectExtractedSizeBudgetExceeded(params: {
extractArchive({
archivePath: params.archivePath,
destDir: params.destDir,
timeoutMs: params.timeoutMs ?? 5_000,
timeoutMs: params.timeoutMs ?? ARCHIVE_EXTRACT_TIMEOUT_MS,
limits: { maxExtractedBytes: params.maxExtractedBytes },
}),
).rejects.toThrow("archive extracted size exceeds limit");
@ -93,7 +94,11 @@ describe("archive utils", () => {
fileName: "hello.txt",
content: "hi",
});
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
await extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
});
const rootDir = await resolvePackedRootDir(extractDir);
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
expect(content).toBe("hi");
@ -118,7 +123,11 @@ describe("archive utils", () => {
await createDirectorySymlink(realExtractDir, extractDir);
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toMatchObject({
code: "destination-symlink",
} satisfies Partial<ArchiveSecurityError>);
@ -135,7 +144,11 @@ describe("archive utils", () => {
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toThrow(/(escapes destination|absolute)/i);
});
});
@ -151,7 +164,11 @@ describe("archive utils", () => {
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
@ -186,7 +203,11 @@ describe("archive utils", () => {
timing: "after-realpath",
run: async () => {
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
@ -222,7 +243,11 @@ describe("archive utils", () => {
try {
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
@ -245,7 +270,11 @@ describe("archive utils", () => {
await tar.c({ cwd: insideDir, file: archivePath }, ["../outside.txt"]);
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toThrow(/escapes destination/i);
});
});
@ -261,7 +290,11 @@ describe("archive utils", () => {
await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]);
await expect(
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toMatchObject({
code: "destination-symlink-traversal",
} satisfies Partial<ArchiveSecurityError>);
@ -308,7 +341,7 @@ describe("archive utils", () => {
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: 5_000,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
limits: { maxArchiveBytes: Math.max(1, stat.size - 1) },
}),
).rejects.toThrow("archive size exceeds limit");
@ -328,7 +361,7 @@ describe("archive utils", () => {
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: 5_000,
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
}),
).rejects.toThrow(/absolute|drive path|escapes destination/i);
});

View File

@ -2,8 +2,13 @@
export { getAcpSessionManager } from "../acp/control-plane/manager.js";
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
export { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../acp/runtime/registry.js";
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
export {
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
requireAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "../acp/runtime/registry.js";
export type {
AcpRuntime,
AcpRuntimeCapabilities,

View File

@ -8,11 +8,11 @@ const shouldWarnCompatImport =
if (shouldWarnCompatImport) {
process.emitWarning(
"openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports.",
"openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports. See https://docs.openclaw.ai/plugins/sdk-migration",
{
code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED",
detail:
"Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.",
"Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration",
},
);
}

View File

@ -3,11 +3,11 @@ export type { LineConfig } from "../line/types.js";
export {
createTopLevelChannelDmPolicy,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
splitSetupEntries,
} from "./setup.js";
export { formatDocsLink } from "../terminal/links.js";
export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js";
export {
listLineAccountIds,

View File

@ -43,3 +43,7 @@ export {
normalizeOptionalSecretInput,
normalizeSecretInput,
} from "../utils/normalize-secret-input.js";
export {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "../secrets/provider-env-vars.js";

View File

@ -62,6 +62,14 @@ function resolveControlCommandGate(params) {
return { commandAuthorized, shouldBlock };
}
function onDiagnosticEvent(listener) {
const monolithic = loadMonolithicSdk();
if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") {
throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent");
}
return monolithic.onDiagnosticEvent(listener);
}
function getPackageRoot() {
return path.resolve(__dirname, "..", "..");
}
@ -152,6 +160,7 @@ function tryLoadMonolithicSdk() {
const fastExports = {
emptyPluginConfigSchema,
onDiagnosticEvent,
resolveControlCommandGate,
};

View File

@ -180,7 +180,11 @@ describe("plugin-sdk root alias", () => {
const lazyRootSdk = lazyModule.moduleExports;
expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function");
expect(lazyRootSdk.onDiagnosticEvent).toBe(onDiagnosticEvent);
expect(
typeof (lazyRootSdk.onDiagnosticEvent as (listener: () => void) => () => void)(
() => undefined,
),
).toBe("function");
expect("onDiagnosticEvent" in lazyRootSdk).toBe(true);
});

View File

@ -34,14 +34,14 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { probeIMessage } from "./src/probe.js";',
'export { sendMessageIMessage } from "./src/send.js";',
],
"extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'],
"extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'],
"extensions/matrix/runtime-api.ts": [
'export * from "./src/auth-precedence.js";',
'export * from "./helper-api.js";',
'export * from "./thread-bindings-runtime.js";',
],
"extensions/nextcloud-talk/runtime-api.ts": [
'export * from "../../src/plugin-sdk/nextcloud-talk.js";',
'export * from "openclaw/plugin-sdk/nextcloud-talk";',
],
"extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'],
"extensions/slack/runtime-api.ts": [

View File

@ -61,30 +61,15 @@ describe("plugin-sdk subpath exports", () => {
expect(pluginSdkSubpaths).not.toContain("acpx");
expect(pluginSdkSubpaths).not.toContain("compat");
expect(pluginSdkSubpaths).not.toContain("device-pair");
expect(pluginSdkSubpaths).not.toContain("feishu");
expect(pluginSdkSubpaths).not.toContain("google");
expect(pluginSdkSubpaths).not.toContain("googlechat");
expect(pluginSdkSubpaths).not.toContain("irc");
expect(pluginSdkSubpaths).not.toContain("line");
expect(pluginSdkSubpaths).not.toContain("line-core");
expect(pluginSdkSubpaths).not.toContain("lobster");
expect(pluginSdkSubpaths).not.toContain("mattermost");
expect(pluginSdkSubpaths).not.toContain("msteams");
expect(pluginSdkSubpaths).not.toContain("nextcloud-talk");
expect(pluginSdkSubpaths).not.toContain("nostr");
expect(pluginSdkSubpaths).not.toContain("pairing-access");
expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth");
expect(pluginSdkSubpaths).not.toContain("reply-prefix");
expect(pluginSdkSubpaths).not.toContain("signal");
expect(pluginSdkSubpaths).not.toContain("signal-core");
expect(pluginSdkSubpaths).not.toContain("synology-chat");
expect(pluginSdkSubpaths).not.toContain("tlon");
expect(pluginSdkSubpaths).not.toContain("twitch");
expect(pluginSdkSubpaths).not.toContain("typing");
expect(pluginSdkSubpaths).not.toContain("voice-call");
expect(pluginSdkSubpaths).not.toContain("zalo");
expect(pluginSdkSubpaths).not.toContain("zai");
expect(pluginSdkSubpaths).not.toContain("zalouser");
expect(pluginSdkSubpaths).not.toContain("provider-model-definitions");
});

View File

@ -0,0 +1,9 @@
import JSON5 from "json5";
export function parseJsonWithJson5Fallback(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return JSON5.parse(raw);
}
}

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
dedupeFilesPreserveOrder,
packFilesByDuration,
selectMemoryHeavyFiles,
selectTimedHeavyFiles,
selectUnitHeavyFileGroups,
@ -91,3 +93,44 @@ describe("scripts/test-runner-manifest memory selection", () => {
});
});
});
describe("dedupeFilesPreserveOrder", () => {
it("removes duplicates while keeping the first-seen order", () => {
expect(
dedupeFilesPreserveOrder([
"src/b.test.ts",
"src/a.test.ts",
"src/b.test.ts",
"src/c.test.ts",
"src/a.test.ts",
]),
).toEqual(["src/b.test.ts", "src/a.test.ts", "src/c.test.ts"]);
});
it("filters excluded files before deduping", () => {
expect(
dedupeFilesPreserveOrder(
["src/a.test.ts", "src/b.test.ts", "src/c.test.ts", "src/b.test.ts"],
new Set(["src/b.test.ts"]),
),
).toEqual(["src/a.test.ts", "src/c.test.ts"]);
});
});
describe("packFilesByDuration", () => {
it("packs heavier files into the lightest remaining bucket", () => {
const durationByFile = {
"src/a.test.ts": 100,
"src/b.test.ts": 90,
"src/c.test.ts": 20,
"src/d.test.ts": 10,
} satisfies Record<string, number>;
expect(
packFilesByDuration(Object.keys(durationByFile), 2, (file) => durationByFile[file] ?? 0),
).toEqual([
["src/a.test.ts", "src/d.test.ts"],
["src/b.test.ts", "src/c.test.ts"],
]);
});
});

View File

@ -1062,7 +1062,7 @@ export function renderConfig(props: ConfigProps) {
}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw JSON5
Raw config (JSON/JSON5)
${
sensitiveCount > 0
? html`
@ -1087,7 +1087,7 @@ export function renderConfig(props: ConfigProps) {
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw JSON5 config"}
placeholder=${blurred ? REDACTED_PLACEHOLDER : "Raw config (JSON/JSON5)"}
.value=${blurred ? "" : props.raw}
?readonly=${blurred}
@input=${(e: Event) => {