diff --git a/AGENTS.md b/AGENTS.md index e6c5b1a5e92..daaa0b1ebd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 -- [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 + what’s covered: `docs/help/testing.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e87940da03e..3b967853213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/docs.json b/docs/docs.json index cf4dbc27619..1f77e3d35ab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1076,6 +1076,7 @@ "group": "Extensions", "pages": [ "plugins/building-extensions", + "plugins/sdk-migration", "plugins/architecture", "plugins/community", "plugins/bundles", diff --git a/docs/install/azure.md b/docs/install/azure.md index 7c6abae64fe..012434bc43f 100644 --- a/docs/install/azure.md +++ b/docs/install/azure.md @@ -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`). diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md new file mode 100644 index 00000000000..7ae4e514c94 --- /dev/null +++ b/docs/plugins/sdk-migration.md @@ -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/`). 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/ 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/` +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) diff --git a/docs/reference/test.md b/docs/reference/test.md index e337e963e1d..08ebb2af3fc 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -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 don’t 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=`. - `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`. diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index cde6bbf5569..ece8df41cca 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -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"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index cd47c0e56c7..df946f8ec4a 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -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"; diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 96e4bdbbe90..40f35e1ad53 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -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"; diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts index 53f1be0c51c..b40e5c76e0e 100644 --- a/extensions/line/runtime-api.ts +++ b/extensions/line/runtime-api.ts @@ -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"; diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index 1b10989b45c..3894210f0a6 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -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, ""); diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts index eaf30e04cf7..e6b4fa0ba95 100644 --- a/extensions/line/src/group-policy.ts +++ b/extensions/line/src/group-policy.ts @@ -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; diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 7e894d2b87a..363b4dcb2a1 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -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; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index 6f46cc92217..640ad3812b8 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -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, diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index 2bc65439262..d4e591c8c1e 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -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"; diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index e2b75780399..d32cb7b65d5 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -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"; diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 9da78c1ed61..43a66e95c3f 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -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"); }); diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 2644092f127..92f161341de 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -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({ diff --git a/extensions/msteams/src/monitor-handler.file-consent.test.ts b/extensions/msteams/src/monitor-handler.file-consent.test.ts index 5e610bfcfa6..39b6ea1b1ff 100644 --- a/extensions/msteams/src/monitor-handler.file-consent.test.ts +++ b/extensions/msteams/src/monitor-handler.file-consent.test.ts @@ -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; diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index 80bc1b1dc7b..b2093a7a057 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -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"; diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts index 602b0ac81b7..29825771891 100644 --- a/extensions/nostr/runtime-api.ts +++ b/extensions/nostr/runtime-api.ts @@ -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"; diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 172943641f8..6aeeef0adb1 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -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"; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 14992a5f631..f3c07df7c87 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 6b9e2a766d2..a5f9cb58c89 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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(); } diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index e1f464c52a5..6222e913461 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -491,9 +491,7 @@ async function maybePinFirstDeliveredMessage(params: { } } -function emitMessageSentHooks(params: { - hookRunner: ReturnType; - 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; + 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; diff --git a/extensions/telegram/src/bot/delivery.ts b/extensions/telegram/src/bot/delivery.ts index bbe599f46b0..7a07433e1ed 100644 --- a/extensions/telegram/src/bot/delivery.ts +++ b/extensions/telegram/src/bot/delivery.ts @@ -1,2 +1,2 @@ -export { deliverReplies } from "./delivery.replies.js"; +export { deliverReplies, emitInternalMessageSentHook } from "./delivery.replies.js"; export { resolveMedia } from "./delivery.resolve-media.js"; diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index c67a091995e..7ecf392eb24 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -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; @@ -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["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 => { + }): Promise => { 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"); }; } diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index aba9974eff5..3470a6257c8 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -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["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" }), ); diff --git a/extensions/tlon/runtime-api.ts b/extensions/tlon/runtime-api.ts index 3ba9718868f..3c2c83655c5 100644 --- a/extensions/tlon/runtime-api.ts +++ b/extensions/tlon/runtime-api.ts @@ -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"; diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts index 9d055202a39..87433b1997f 100644 --- a/extensions/twitch/runtime-api.ts +++ b/extensions/twitch/runtime-api.ts @@ -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"; diff --git a/extensions/voice-call/runtime-api.ts b/extensions/voice-call/runtime-api.ts index f0b32548645..9dd4fb0f3bc 100644 --- a/extensions/voice-call/runtime-api.ts +++ b/extensions/voice-call/runtime-api.ts @@ -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"; diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index 082f65d43b8..90ced0da803 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -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"; diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index 1b63edaea42..7d931f2d118 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -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"; diff --git a/package.json b/package.json index a522ced8380..91abc6172a7 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e5dad4777eb..1dc306bd9b7 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -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", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 011211a307b..5bbd4c94ac6 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -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; } diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index 4e0ff9d0a5a..ee5644f3328 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -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; +} diff --git a/src/agents/subagent-depth.test.ts b/src/agents/subagent-depth.test.ts index 5d9427b7818..2c62432a692 100644 --- a/src/agents/subagent-depth.test.ts +++ b/src/agents/subagent-depth.test.ts @@ -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, { diff --git a/src/agents/subagent-depth.ts b/src/agents/subagent-depth.ts index 8b62539ac45..53fa6bfe7c5 100644 --- a/src/agents/subagent-depth.ts +++ b/src/agents/subagent-depth.ts @@ -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 { 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; } diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d30a476004d..6e9cc07bf7e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -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"); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 604e27666c9..e7a94ae99ab 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -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", diff --git a/src/config/paths.ts b/src/config/paths.ts index 84c27749bcf..a35a1a3d03d 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -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) */ diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index f511636fb85..405d04cbe60 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -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); diff --git a/src/cron/store.ts b/src/cron/store.ts index 8e8f0440f35..be286806f2c 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -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 { 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, diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index d77b1e0bdb4..5f62200314e 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); }); diff --git a/src/plugin-sdk/acp-runtime.ts b/src/plugin-sdk/acp-runtime.ts index 7767d042f9b..1657cb7cace 100644 --- a/src/plugin-sdk/acp-runtime.ts +++ b/src/plugin-sdk/acp-runtime.ts @@ -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, diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 99e2066633c..643557f0960 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -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/ imports.", + "openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/ 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", }, ); } diff --git a/src/plugin-sdk/line-core.ts b/src/plugin-sdk/line-core.ts index 04b2950a50d..596593fc8f4 100644 --- a/src/plugin-sdk/line-core.ts +++ b/src/plugin-sdk/line-core.ts @@ -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, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 13125b7704c..bdc73f50793 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -43,3 +43,7 @@ export { normalizeOptionalSecretInput, normalizeSecretInput, } from "../utils/normalize-secret-input.js"; +export { + listKnownProviderAuthEnvVarNames, + omitEnvKeysCaseInsensitive, +} from "../secrets/provider-env-vars.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 669586bb80c..11ffc459ef2 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -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, }; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 48ae4a7b43c..37072f9ded7 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -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); }); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 2158edff7d0..afa32af0b7f 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -34,14 +34,14 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { '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": [ diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index ab8c16d71f7..566dc6645e1 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -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"); }); diff --git a/src/utils/parse-json-compat.ts b/src/utils/parse-json-compat.ts new file mode 100644 index 00000000000..2f5ab442526 --- /dev/null +++ b/src/utils/parse-json-compat.ts @@ -0,0 +1,9 @@ +import JSON5 from "json5"; + +export function parseJsonWithJson5Fallback(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return JSON5.parse(raw); + } +} diff --git a/test/scripts/test-runner-manifest.test.ts b/test/scripts/test-runner-manifest.test.ts index cd650ae2aad..0fac87c25e1 100644 --- a/test/scripts/test-runner-manifest.test.ts +++ b/test/scripts/test-runner-manifest.test.ts @@ -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; + + 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"], + ]); + }); +}); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 7c1121e6bb8..6e3db2c6a67 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1062,7 +1062,7 @@ export function renderConfig(props: ConfigProps) { }
- Raw JSON5 + Raw config (JSON/JSON5) ${ sensitiveCount > 0 ? html` @@ -1087,7 +1087,7 @@ export function renderConfig(props: ConfigProps) {