From 430c293ee04ec940775aff0df09c139ebab36cb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 00:04:52 -0400 Subject: [PATCH] refactor: remove duplicate provider and runtime code (#115529) * refactor: remove duplicated runtime and provider code * refactor: exclude unrelated browser test cleanup * refactor: preserve canonical subagent cleanup ordering --- extensions/clickclack/src/setup-core.ts | 23 +- extensions/cohere/onboard.ts | 13 +- .../fal/image-generation-provider.test.ts | 226 ++----- extensions/featherless/onboard.ts | 13 +- extensions/feishu/src/policy.ts | 68 +- extensions/fireworks/onboard.ts | 13 +- extensions/huggingface/onboard.ts | 13 +- extensions/kilocode/onboard.ts | 13 +- extensions/line/src/setup-core.ts | 54 +- extensions/litellm/onboard.ts | 41 +- extensions/meta/onboard.ts | 15 +- extensions/qianfan/onboard.ts | 6 +- extensions/synthetic/onboard.ts | 20 +- extensions/telegram/src/bot.test.ts | 364 ++++------ extensions/tencent/onboard.ts | 21 +- extensions/whatsapp/src/setup-core.ts | 51 +- extensions/xiaomi/onboard.ts | 41 +- extensions/zalo/src/setup-core.ts | 57 +- .../check-database-first-legacy-stores.mjs | 182 +---- src/agents/subagent-registry.test.ts | 592 +++++++--------- src/auto-reply/reply/session.test.ts | 634 ++++++------------ .../test-helpers/registry-session-binding.ts | 396 ++++------- src/cli/update-cli.test.ts | 313 +++------ .../legacy-config-migrations.channels.ts | 131 +--- .../legacy-config-migrations.runtime.tts.ts | 140 ++-- src/gateway/session-utils.fs.ts | 109 +-- src/gateway/watch-node-http.test.ts | 281 +++----- 27 files changed, 1178 insertions(+), 2652 deletions(-) diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 2f055804f346..2ae59998d6ae 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -9,9 +9,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "openclaw/plugin-sdk/setup"; import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime"; import { resolveClickClackAccountConfig } from "./accounts.js"; @@ -171,21 +171,14 @@ export function applyClickClackSetupConfigPatch(params: { channelKey: channel, setupSurface: clickClackSetupAdapter, }); - const namedConfig = applyAccountNameToChannelSection({ - cfg: scopedConfig, - channelKey: channel, - accountId, - name: params.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; return patchScopedAccountConfig({ - cfg: next, + cfg: prepareScopedSetupConfig({ + cfg: scopedConfig, + channelKey: channel, + accountId, + name: params.name, + migrateBaseName: accountId !== DEFAULT_ACCOUNT_ID, + }), channelKey: channel, accountId, patch: params.patch, diff --git a/extensions/cohere/onboard.ts b/extensions/cohere/onboard.ts index d80b562972ba..6e8ebcc5be82 100644 --- a/extensions/cohere/onboard.ts +++ b/extensions/cohere/onboard.ts @@ -1,16 +1,13 @@ import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildCohereCatalogModels, COHERE_BASE_URL } from "./models.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; const COHERE_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "cohere")!; -const coherePresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyCohereConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: COHERE_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "cohere", api: "openai-completions", baseUrl: COHERE_BASE_URL, @@ -18,7 +15,3 @@ const coherePresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: COHERE_DEFAULT_MODEL_REF, alias: "Cohere Command A+" }], }), }); - -export function applyCohereConfig(cfg: OpenClawConfig): OpenClawConfig { - return coherePresetAppliers.applyConfig(cfg); -} diff --git a/extensions/fal/image-generation-provider.test.ts b/extensions/fal/image-generation-provider.test.ts index 36c1c038e21a..9fa078229e81 100644 --- a/extensions/fal/image-generation-provider.test.ts +++ b/extensions/fal/image-generation-provider.test.ts @@ -9,6 +9,15 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ import { buildFalImageGenerationProvider } from "./image-generation-provider.js"; import { setFalFetchGuardForTesting } from "./test-support.js"; +function mockFalImageProviderRuntime() { + vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "fal-test-key", + source: "env", + mode: "api-key", + }); + setFalFetchGuardForTesting(fetchWithSsrFGuardMock); +} + function expectFalJsonPost(params: { call: number; url: string; body: Record }) { const request = fetchWithSsrFGuardMock.mock.calls[params.call - 1]?.[0]; if (!request) { @@ -74,12 +83,7 @@ describe("fal image-generation provider", () => { }); it("generates image buffers from the fal sync API", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const releaseRequest = vi.fn(async () => {}); const releaseDownload = vi.fn(async () => {}); fetchWithSsrFGuardMock @@ -216,12 +220,7 @@ describe("fal image-generation provider", () => { }); it("releases a timed-out generated image download", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const releaseDownload = vi.fn(async () => {}); fetchWithSsrFGuardMock .mockResolvedValueOnce({ @@ -257,12 +256,7 @@ describe("fal image-generation provider", () => { }); it("rejects generated image downloads that exceed the configured media cap", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -296,12 +290,7 @@ describe("fal image-generation provider", () => { }); it("wraps wrong-shape successful fal image responses", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: new Response( JSON.stringify({ images: { url: "https://example.test/image.png" } }), @@ -325,12 +314,7 @@ describe("fal image-generation provider", () => { }); it("uses image-to-image endpoint and data-uri input for edits", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -382,12 +366,7 @@ describe("fal image-generation provider", () => { }); it("routes GPT Image 2 edits through /edit with image_urls", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -439,12 +418,7 @@ describe("fal image-generation provider", () => { }); it("allows GPT Image 2 edits up to 10 reference images", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -495,12 +469,7 @@ describe("fal image-generation provider", () => { }); it("rejects GPT Image 2 edits above 10 reference images", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -519,12 +488,7 @@ describe("fal image-generation provider", () => { }); it("routes Nano Banana 2 text generation with native resolution", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -570,12 +534,7 @@ describe("fal image-generation provider", () => { }); it("does not synthesize Nano Banana 2 aspect ratio from resolution alone", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -622,12 +581,7 @@ describe("fal image-generation provider", () => { { model: "fal-ai/nano-banana", resolution: undefined }, { model: "fal-ai/nano-banana-2", resolution: "2K" as const }, ])("routes $model edits through /edit with model geometry", async ({ model, resolution }) => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -692,12 +646,7 @@ describe("fal image-generation provider", () => { error: "fal Nano Banana 2 supports at most 14 reference images", }, ])("rejects $model edits above its reference limit", async ({ model, inputCount, error }) => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -716,12 +665,7 @@ describe("fal image-generation provider", () => { }); it("rejects Krea-only aspect ratios for Nano Banana 2", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -737,12 +681,7 @@ describe("fal image-generation provider", () => { }); it("routes Nano Banana 2 Lite edits through /edit with image_urls", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -794,12 +733,7 @@ describe("fal image-generation provider", () => { }); it("rejects Krea-only aspect ratios for Nano Banana 2 Lite", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -817,12 +751,7 @@ describe("fal image-generation provider", () => { it.each(["1K", "2K", "4K"] as const)( "rejects %s resolution overrides for Nano Banana 2 Lite", async (resolution) => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -841,12 +770,7 @@ describe("fal image-generation provider", () => { ); it("rejects Nano Banana 2 Lite edits above 14 reference images", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -891,12 +815,7 @@ describe("fal image-generation provider", () => { }, }, ])("keeps $label text-to-image on its base endpoint", async (testCase) => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -936,12 +855,7 @@ describe("fal image-generation provider", () => { }); it("routes Grok Imagine edits through /edit with lowercase resolution", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -989,12 +903,7 @@ describe("fal image-generation provider", () => { }); it("rejects 4K resolution for Grok Imagine edits", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -1012,12 +921,7 @@ describe("fal image-generation provider", () => { }); it("rejects Nano Banana ratios for Grok Imagine", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -1033,12 +937,7 @@ describe("fal image-generation provider", () => { }); it("rejects Grok Imagine edits above 3 reference images", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const provider = buildFalImageGenerationProvider(); await expect( @@ -1057,12 +956,7 @@ describe("fal image-generation provider", () => { }); it("preserves an explicit Grok Imagine /quality/edit model path", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1106,12 +1000,7 @@ describe("fal image-generation provider", () => { }); it("preserves exact custom Fal edit endpoints", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1155,12 +1044,7 @@ describe("fal image-generation provider", () => { }); it("maps aspect ratio for text generation without forcing a square default", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1204,12 +1088,7 @@ describe("fal image-generation provider", () => { }); it("combines resolution and aspect ratio for text generation", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1254,12 +1133,7 @@ describe("fal image-generation provider", () => { }); it("uses Krea 2 native aspect-ratio and creativity payload schema", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1308,12 +1182,7 @@ describe("fal image-generation provider", () => { }); it("passes reference images to Krea 2 as style references without edit suffix", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1364,12 +1233,7 @@ describe("fal image-generation provider", () => { }); it("maps Krea 2 size hints to the closest native aspect ratio", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( @@ -1521,12 +1385,7 @@ describe("fal image-generation provider", () => { }); it("blocks private-network image download URLs through the SSRF guard", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); const blocked = new Error("Blocked: resolves to private/internal/special-use IP address"); fetchWithSsrFGuardMock .mockResolvedValueOnce({ @@ -1560,12 +1419,7 @@ describe("fal image-generation provider", () => { }); it("does not auto-whitelist trusted private relay hosts from a configured baseUrl", async () => { - vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "fal-test-key", - source: "env", - mode: "api-key", - }); - setFalFetchGuardForTesting(fetchWithSsrFGuardMock); + mockFalImageProviderRuntime(); fetchWithSsrFGuardMock .mockResolvedValueOnce({ response: new Response( diff --git a/extensions/featherless/onboard.ts b/extensions/featherless/onboard.ts index 68c8e120d9e8..c5845d3d3d0b 100644 --- a/extensions/featherless/onboard.ts +++ b/extensions/featherless/onboard.ts @@ -1,8 +1,5 @@ // Featherless onboarding applies the curated model catalog and default. -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildFeatherlessCatalogModels, FEATHERLESS_BASE_URL, @@ -11,9 +8,9 @@ import { export { FEATHERLESS_DEFAULT_MODEL_REF } from "./models.js"; -const featherlessPresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyFeatherlessConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: FEATHERLESS_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "featherless", api: "openai-completions", baseUrl: FEATHERLESS_BASE_URL, @@ -21,7 +18,3 @@ const featherlessPresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: FEATHERLESS_DEFAULT_MODEL_REF, alias: "Qwen3 32B" }], }), }); - -export function applyFeatherlessConfig(cfg: OpenClawConfig): OpenClawConfig { - return featherlessPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index d323afdf8fd3..0696f8741dcd 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -9,11 +9,6 @@ import { type ChannelIngressIdentitySubjectInput, type ResolveChannelMessageIngressParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; -import { - resolveScopeKeyCaseInsensitive, - resolveScopeToolsPolicy, - type ScopeTree, -} from "openclaw/plugin-sdk/channel-policy"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ChannelGroupContext } from "../runtime-api.js"; @@ -231,66 +226,47 @@ export async function resolveFeishuGroupSenderActivationIngressAccess(params: { }); } -export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) { +function resolveFeishuExplicitGroupConfigKey(params: { + cfg?: FeishuConfig; + groupId?: string | null; +}): string | undefined { const groups = params.cfg?.groups ?? {}; - const wildcard = groups["*"]; const groupId = params.groupId?.trim(); - if (!groupId) { + if (!groupId || groupId === "*") { return undefined; } - - const direct = groups[groupId]; - if (direct) { - return direct; + if (Object.hasOwn(groups, groupId)) { + return groupId; } - const lowered = normalizeOptionalLowercaseString(groupId) ?? ""; - const matchKey = Object.keys(groups).find( - (key) => normalizeOptionalLowercaseString(key) === lowered, + return Object.keys(groups).find( + (key) => key !== "*" && normalizeOptionalLowercaseString(key) === lowered, ); - if (matchKey) { - return groups[matchKey]; +} + +export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) { + if (!params.groupId?.trim()) { + return undefined; } - return wildcard; + const groups = params.cfg?.groups ?? {}; + const key = resolveFeishuExplicitGroupConfigKey(params); + return key ? groups[key] : groups["*"]; } export function hasExplicitFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null; }): boolean { - const groups = params.cfg?.groups ?? {}; - const groupId = params.groupId?.trim(); - if (!groupId) { - return false; - } - if (Object.hasOwn(groups, groupId) && groupId !== "*") { - return true; - } - - const lowered = normalizeOptionalLowercaseString(groupId) ?? ""; - return Object.keys(groups).some( - (key) => key !== "*" && normalizeOptionalLowercaseString(key) === lowered, - ); + return resolveFeishuExplicitGroupConfigKey(params) !== undefined; } export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) { // This adapter intentionally reads root channels.feishu without account merge; // reply mention policy merges accounts, and changing that asymmetry is product behavior. - const cfg: FeishuConfig | undefined = params.cfg.channels?.feishu; - if (!cfg) { - return undefined; - } - const groups: NonNullable = cfg.groups ?? {}; - // Whole-entry selection: a matched group hides every wildcard field. - const tree: ScopeTree = { - scopes: Object.fromEntries( - Object.entries(groups).map(([key, entry]) => [key, { tools: entry?.tools }]), - ), - }; - const groupId = params.groupId?.trim(); - const matchedKey = resolveScopeKeyCaseInsensitive(tree, groupId); - const scopeKey = groupId && !matchedKey && Object.hasOwn(tree.scopes, "*") ? "*" : matchedKey; - return resolveScopeToolsPolicy({ tree, path: scopeKey ? [scopeKey] : [] }); + return resolveFeishuGroupConfig({ + cfg: params.cfg.channels?.feishu, + groupId: params.groupId, + })?.tools; } export function resolveFeishuReplyPolicy(params: { diff --git a/extensions/fireworks/onboard.ts b/extensions/fireworks/onboard.ts index cd1892db6d26..852f885e137b 100644 --- a/extensions/fireworks/onboard.ts +++ b/extensions/fireworks/onboard.ts @@ -1,7 +1,4 @@ -import { - createDefaultModelsPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createDefaultModelsPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildFireworksCatalogModels, buildFireworksProvider, @@ -9,9 +6,9 @@ import { FIREWORKS_DEFAULT_MODEL_REF, } from "./provider-catalog.js"; -const fireworksPresetAppliers = createDefaultModelsPresetAppliers({ +export const { applyConfig: applyFireworksConfig } = createDefaultModelsPresetAppliers<[]>({ primaryModelRef: FIREWORKS_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => { + resolveParams: () => { const defaultProvider = buildFireworksProvider(); return { providerId: "fireworks", @@ -23,7 +20,3 @@ const fireworksPresetAppliers = createDefaultModelsPresetAppliers({ }; }, }); - -export function applyFireworksConfig(cfg: OpenClawConfig): OpenClawConfig { - return fireworksPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/huggingface/onboard.ts b/extensions/huggingface/onboard.ts index ab6728af2941..661f1bd359cc 100644 --- a/extensions/huggingface/onboard.ts +++ b/extensions/huggingface/onboard.ts @@ -1,8 +1,5 @@ // Huggingface setup module handles plugin onboarding behavior. -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildHuggingfaceModelDefinition, HUGGINGFACE_BASE_URL, @@ -11,9 +8,9 @@ import { export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; -const huggingfacePresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyHuggingfaceConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: HUGGINGFACE_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "huggingface", api: "openai-completions", baseUrl: HUGGINGFACE_BASE_URL, @@ -21,7 +18,3 @@ const huggingfacePresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: HUGGINGFACE_DEFAULT_MODEL_REF, alias: "Hugging Face" }], }), }); - -export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { - return huggingfacePresetAppliers.applyConfig(cfg); -} diff --git a/extensions/kilocode/onboard.ts b/extensions/kilocode/onboard.ts index 303037e8d96c..d0de4ca81b9a 100644 --- a/extensions/kilocode/onboard.ts +++ b/extensions/kilocode/onboard.ts @@ -1,16 +1,13 @@ // Kilocode setup module handles plugin onboarding behavior. -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildKilocodeProvider } from "./provider-catalog.js"; import { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF } from "./provider-models.js"; export { KILOCODE_DEFAULT_MODEL_REF }; -const kilocodePresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyKilocodeConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: KILOCODE_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "kilocode", api: "openai-completions", baseUrl: KILOCODE_BASE_URL, @@ -18,7 +15,3 @@ const kilocodePresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: KILOCODE_DEFAULT_MODEL_REF, alias: "Kilo Gateway" }], }), }); - -export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { - return kilocodePresetAppliers.applyConfig(cfg); -} diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 30949a76b66a..36c7c5d73d4e 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -78,46 +78,28 @@ export const lineSetupAdapter: ChannelSetupAdapter = { // Shipped alias: `--token` writes channelAccessToken; the explicit switch wins. const accessToken = typedInput.channelAccessToken ?? typedInput.token; const normalizedAccountId = normalizeAccountId(accountId); - if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { - return patchLineAccountConfig({ - cfg, - accountId: normalizedAccountId, - enabled: true, - clearFields: typedInput.useEnv - ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] - : undefined, - patch: typedInput.useEnv - ? {} - : { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : accessToken - ? { channelAccessToken: accessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }); - } + const useEnv = normalizedAccountId === DEFAULT_ACCOUNT_ID && Boolean(typedInput.useEnv); return patchLineAccountConfig({ cfg, accountId: normalizedAccountId, enabled: true, - patch: { - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : accessToken - ? { channelAccessToken: accessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, + clearFields: useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : accessToken + ? { channelAccessToken: accessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, }); }, }; diff --git a/extensions/litellm/onboard.ts b/extensions/litellm/onboard.ts index 2455230c1e7a..008ce745e310 100644 --- a/extensions/litellm/onboard.ts +++ b/extensions/litellm/onboard.ts @@ -29,28 +29,21 @@ export function buildLitellmModelDefinition(): ModelDefinitionConfig { }; } -const litellmPresetAppliers = createDefaultModelPresetAppliers({ - primaryModelRef: LITELLM_DEFAULT_MODEL_REF, - resolveParams: (cfg: OpenClawConfig) => { - const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined; - const resolvedBaseUrl = - typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; +export const { applyConfig: applyLitellmConfig, applyProviderConfig: applyLitellmProviderConfig } = + createDefaultModelPresetAppliers<[]>({ + primaryModelRef: LITELLM_DEFAULT_MODEL_REF, + resolveParams: (cfg: OpenClawConfig) => { + const existingProvider = cfg.models?.providers?.litellm as { baseUrl?: unknown } | undefined; + const resolvedBaseUrl = + typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; - return { - providerId: "litellm", - api: "openai-completions" as const, - baseUrl: resolvedBaseUrl || LITELLM_BASE_URL, - defaultModel: buildLitellmModelDefinition(), - defaultModelId: LITELLM_DEFAULT_MODEL_ID, - aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }], - }; - }, -}); - -export function applyLitellmProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return litellmPresetAppliers.applyProviderConfig(cfg); -} - -export function applyLitellmConfig(cfg: OpenClawConfig): OpenClawConfig { - return litellmPresetAppliers.applyConfig(cfg); -} + return { + providerId: "litellm", + api: "openai-completions" as const, + baseUrl: resolvedBaseUrl || LITELLM_BASE_URL, + defaultModel: buildLitellmModelDefinition(), + defaultModelId: LITELLM_DEFAULT_MODEL_ID, + aliases: [{ modelRef: LITELLM_DEFAULT_MODEL_REF, alias: "LiteLLM" }], + }; + }, + }); diff --git a/extensions/meta/onboard.ts b/extensions/meta/onboard.ts index ac9b96586611..e252b358ceba 100644 --- a/extensions/meta/onboard.ts +++ b/extensions/meta/onboard.ts @@ -2,19 +2,17 @@ * Meta onboarding config helpers. */ import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildMetaCatalogModels, META_BASE_URL } from "./models.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; /** Default Meta model reference used after onboarding. */ export const META_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "meta")!; -const metaPresetAppliers = createModelCatalogPresetAppliers({ +/** Applies Meta provider/catalog config and default model aliases. */ +export const { applyConfig: applyMetaConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: META_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "meta", api: "openai-responses", baseUrl: META_BASE_URL, @@ -22,8 +20,3 @@ const metaPresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: META_DEFAULT_MODEL_REF, alias: "Muse Spark 1.1" }], }), }); - -/** Applies Meta provider/catalog config and default model aliases. */ -export function applyMetaConfig(cfg: OpenClawConfig): OpenClawConfig { - return metaPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/qianfan/onboard.ts b/extensions/qianfan/onboard.ts index 2cc5d2dcada6..c20fcc23d3bd 100644 --- a/extensions/qianfan/onboard.ts +++ b/extensions/qianfan/onboard.ts @@ -38,7 +38,7 @@ function resolveQianfanPreset(cfg: OpenClawConfig): { }; } -const qianfanPresetAppliers = createDefaultModelsPresetAppliers({ +export const { applyConfig: applyQianfanConfig } = createDefaultModelsPresetAppliers<[]>({ primaryModelRef: QIANFAN_DEFAULT_MODEL_REF, resolveParams: (cfg: OpenClawConfig) => { const preset = resolveQianfanPreset(cfg); @@ -52,7 +52,3 @@ const qianfanPresetAppliers = createDefaultModelsPresetAppliers({ }; }, }); - -export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { - return qianfanPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/synthetic/onboard.ts b/extensions/synthetic/onboard.ts index 2515d493a6ea..e43d317409a7 100644 --- a/extensions/synthetic/onboard.ts +++ b/extensions/synthetic/onboard.ts @@ -1,8 +1,5 @@ // Synthetic setup module handles plugin onboarding behavior. -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { buildSyntheticModelDefinition, SYNTHETIC_BASE_URL, @@ -12,9 +9,12 @@ import { export { SYNTHETIC_DEFAULT_MODEL_REF }; -const syntheticPresetAppliers = createModelCatalogPresetAppliers({ +export const { + applyConfig: applySyntheticConfig, + applyProviderConfig: applySyntheticProviderConfig, +} = createModelCatalogPresetAppliers<[]>({ primaryModelRef: SYNTHETIC_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: "synthetic", api: "anthropic-messages", baseUrl: SYNTHETIC_BASE_URL, @@ -22,11 +22,3 @@ const syntheticPresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: SYNTHETIC_DEFAULT_MODEL_REF, alias: "MiniMax M3" }], }), }); - -export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return syntheticPresetAppliers.applyProviderConfig(cfg); -} - -export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { - return syntheticPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index a723188baacc..38408359b7ed 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -124,6 +124,17 @@ const FIRE_EMOJI = "\u{1F525}"; const PARTY_EMOJI = "\u{1F389}"; const EYES_EMOJI = "\u{1F440}"; const HEART_EMOJI = "\u{2764}\u{FE0F}"; + +type TelegramReactionPolicyCase = { + name: string; + updateId: number; + channelConfig: NonNullable["telegram"]>; + reaction?: Record; + sentByBot?: boolean; + expectedEnqueueCalls: number; + expectedEvent?: string; +}; + async function withTelegramSpooledReplayUpdate( update: object, fn: () => Promise, @@ -6214,7 +6225,7 @@ describe("createTelegramBot", () => { expect(String(systemEventOptions().contextKey)).toContain("telegram:reaction:add:1234:42:9"); }); - it.each([ + const telegramReactionPolicyCases: TelegramReactionPolicyCase[] = [ { name: "blocks reaction when dmPolicy is disabled", updateId: 510, @@ -6271,243 +6282,123 @@ describe("createTelegramBot", () => { }, expectedEnqueueCalls: 0, }, - ])("$name", async ({ updateId, channelConfig, reaction, expectedEnqueueCalls }) => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: channelConfig, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler(createTelegramReactionContext({ updateId, reaction })); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls); - }); - - it("skips reaction when reactionNotifications is off", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(true); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", reactionNotifications: "off" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 501 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 42, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], - }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); - - it("defaults reactionNotifications to own", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(true); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 502 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 43, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], - }, - }); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); - }); - - it("allows reaction in all mode regardless of message sender", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(false); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 503 }, - messageReaction: { - chat: { id: 1234, type: "private" }, + { + name: "skips reaction when reactionNotifications is off", + updateId: 501, + channelConfig: { dmPolicy: "open", reactionNotifications: "off" }, + sentByBot: true, + expectedEnqueueCalls: 0, + }, + { + name: "defaults reactionNotifications to own", + updateId: 502, + channelConfig: { dmPolicy: "open", allowFrom: ["*"] }, + reaction: { message_id: 43 }, + sentByBot: true, + expectedEnqueueCalls: 1, + }, + { + name: "allows reaction in all mode regardless of message sender", + updateId: 503, + channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, + reaction: { message_id: 99, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }], }, - }); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); - expect(firstSystemEventArg(0)).toBe(`Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`); - expect(firstSystemEventArg(1)).toBeTypeOf("object"); - }); - - it("skips reaction in own mode when message is not sent by bot", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(false); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 503 }, - messageReaction: { - chat: { id: 1234, type: "private" }, + sentByBot: false, + expectedEnqueueCalls: 1, + expectedEvent: `Telegram reaction added: ${PARTY_EMOJI} by Ada on msg 99`, + }, + { + name: "skips reaction in own mode when message is not sent by bot", + updateId: 503, + channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" }, + reaction: { message_id: 99, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }], }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); - - it("allows reaction in own mode when message is sent by bot", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(true); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 503 }, - messageReaction: { - chat: { id: 1234, type: "private" }, + sentByBot: false, + expectedEnqueueCalls: 0, + }, + { + name: "allows reaction in own mode when message is sent by bot", + updateId: 503, + channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "own" }, + reaction: { message_id: 99, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }], }, - }); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); - }); - - it("skips reaction from bot users", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(true); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 503 }, - messageReaction: { - chat: { id: 1234, type: "private" }, + sentByBot: true, + expectedEnqueueCalls: 1, + }, + { + name: "skips reaction from bot users", + updateId: 503, + channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, + reaction: { message_id: 99, user: { id: 9, first_name: "Bot", is_bot: true }, - date: 1736380800, - old_reaction: [], new_reaction: [{ type: "emoji", emoji: PARTY_EMOJI }], }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); - - it("skips reaction removal (only processes added reactions)", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 504 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 42, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, + sentByBot: true, + expectedEnqueueCalls: 0, + }, + { + name: "skips reaction removal (only processes added reactions)", + updateId: 504, + channelConfig: { dmPolicy: "open", allowFrom: ["*"], reactionNotifications: "all" }, + reaction: { old_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], new_reaction: [], }, - }); + expectedEnqueueCalls: 0, + }, + { + name: "blocks reaction in own mode when cache is warm and message not sent by bot", + updateId: 601, + channelConfig: { dmPolicy: "open", reactionNotifications: "own" }, + reaction: { message_id: 99 }, + sentByBot: false, + expectedEnqueueCalls: 0, + }, + ]; - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); + it.each(telegramReactionPolicyCases)( + "$name", + async ({ + updateId, + channelConfig, + reaction, + sentByBot, + expectedEnqueueCalls, + expectedEvent, + }) => { + onSpy.mockClear(); + enqueueSystemEventSpy.mockClear(); + if (sentByBot !== undefined) { + wasSentByBot.mockReturnValue(sentByBot); + } + + loadConfig.mockReturnValue({ + channels: { + telegram: channelConfig, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message_reaction") as ( + ctx: Record, + ) => Promise; + + await handler(createTelegramReactionContext({ updateId, reaction })); + + expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(expectedEnqueueCalls); + if (expectedEvent) { + expect(firstSystemEventArg(0)).toBe(expectedEvent); + expect(firstSystemEventArg(1)).toBeTypeOf("object"); + } + }, + ); it("enqueues one event per added emoji reaction", async () => { onSpy.mockClear(); @@ -6658,36 +6549,5 @@ describe("createTelegramBot", () => { const sessionKey = eventOptions.sessionKey ?? ""; expect(sessionKey).not.toContain(":topic:"); }); - - it("blocks reaction in own mode when cache is warm and message not sent by bot", async () => { - onSpy.mockClear(); - enqueueSystemEventSpy.mockClear(); - wasSentByBot.mockReturnValue(false); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open", reactionNotifications: "own" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message_reaction") as ( - ctx: Record, - ) => Promise; - - await handler({ - update: { update_id: 601 }, - messageReaction: { - chat: { id: 1234, type: "private" }, - message_id: 99, - user: { id: 9, first_name: "Ada" }, - date: 1736380800, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: THUMBS_UP_EMOJI }], - }, - }); - - expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); - }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/tencent/onboard.ts b/extensions/tencent/onboard.ts index 517c202629b1..9898be42c669 100644 --- a/extensions/tencent/onboard.ts +++ b/extensions/tencent/onboard.ts @@ -1,8 +1,5 @@ import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { - createModelCatalogPresetAppliers, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; +import { createModelCatalogPresetAppliers } from "openclaw/plugin-sdk/provider-onboard"; import { TOKENHUB_BASE_URL, TOKENHUB_MODEL_CATALOG, @@ -19,9 +16,9 @@ export const TOKENHUB_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef( TOKENHUB_PROVIDER_ID, )!; -const tokenHubPresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyTokenHubConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: TOKENHUB_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: TOKENHUB_PROVIDER_ID, api: "openai-completions", baseUrl: TOKENHUB_BASE_URL, @@ -33,18 +30,14 @@ const tokenHubPresetAppliers = createModelCatalogPresetAppliers({ }), }); -export function applyTokenHubConfig(cfg: OpenClawConfig): OpenClawConfig { - return tokenHubPresetAppliers.applyConfig(cfg); -} - export const TOKENPLAN_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef( manifest, TOKENPLAN_PROVIDER_ID, )!; -const tokenPlanPresetAppliers = createModelCatalogPresetAppliers({ +export const { applyConfig: applyTokenPlanConfig } = createModelCatalogPresetAppliers<[]>({ primaryModelRef: TOKENPLAN_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => ({ + resolveParams: () => ({ providerId: TOKENPLAN_PROVIDER_ID, api: "openai-completions", baseUrl: TOKENPLAN_BASE_URL, @@ -52,7 +45,3 @@ const tokenPlanPresetAppliers = createModelCatalogPresetAppliers({ aliases: [{ modelRef: TOKENPLAN_DEFAULT_MODEL_REF, alias: "Hy3 (TokenPlan)" }], }), }); - -export function applyTokenPlanConfig(cfg: OpenClawConfig): OpenClawConfig { - return tokenPlanPresetAppliers.applyConfig(cfg); -} diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index 64859cc79646..51d8310bae12 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,11 +1,9 @@ import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Whatsapp plugin module implements setup core behavior. import { - applyAccountNameToChannelSection, + createPatchedAccountSetupAdapter, type ChannelSetupAdapter, type ChannelSetupInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, } from "openclaw/plugin-sdk/setup"; const channel = "whatsapp" as const; @@ -15,49 +13,12 @@ type WhatsAppSetupInput = ChannelSetupInput & { }; export const whatsappSetupAdapter: ChannelSetupAdapter = { + ...createPatchedAccountSetupAdapter({ + channelKey: channel, + alwaysUseAccounts: true, + buildPatch: (input) => (input.authDir ? { authDir: input.authDir } : {}), + }), singleAccountKeysToMove: ["authDir"], - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as WhatsAppSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: setupInput.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(setupInput.authDir ? { authDir: setupInput.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, }; export const whatsappSetupContract = defineChannelSetupContract({ diff --git a/extensions/xiaomi/onboard.ts b/extensions/xiaomi/onboard.ts index f80ecb201272..e287079aaf36 100644 --- a/extensions/xiaomi/onboard.ts +++ b/extensions/xiaomi/onboard.ts @@ -17,24 +17,25 @@ import { export const XIAOMI_DEFAULT_MODEL_REF = `${XIAOMI_PROVIDER_ID}/${XIAOMI_DEFAULT_MODEL_ID}`; export const XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF = `${XIAOMI_TOKEN_PLAN_PROVIDER_ID}/${XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_ID}`; -const xiaomiPresetAppliers = createDefaultModelsPresetAppliers({ - primaryModelRef: XIAOMI_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => { - const defaultProvider = buildXiaomiProvider(); - return { - providerId: XIAOMI_PROVIDER_ID, - api: defaultProvider.api ?? "openai-completions", - baseUrl: defaultProvider.baseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: XIAOMI_DEFAULT_MODEL_ID, - aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }], - }; - }, -}); +export const { applyConfig: applyXiaomiConfig, applyProviderConfig: applyXiaomiProviderConfig } = + createDefaultModelsPresetAppliers<[]>({ + primaryModelRef: XIAOMI_DEFAULT_MODEL_REF, + resolveParams: () => { + const defaultProvider = buildXiaomiProvider(); + return { + providerId: XIAOMI_PROVIDER_ID, + api: defaultProvider.api ?? "openai-completions", + baseUrl: defaultProvider.baseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: XIAOMI_DEFAULT_MODEL_ID, + aliases: [{ modelRef: XIAOMI_DEFAULT_MODEL_REF, alias: "Xiaomi" }], + }; + }, + }); -const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers({ +const xiaomiTokenPlanPresetAppliers = createDefaultModelsPresetAppliers<[]>({ primaryModelRef: XIAOMI_TOKEN_PLAN_DEFAULT_MODEL_REF, - resolveParams: (_cfg: OpenClawConfig) => { + resolveParams: () => { const defaultProvider = buildXiaomiTokenPlanProvider(); return { providerId: XIAOMI_TOKEN_PLAN_PROVIDER_ID, @@ -78,14 +79,6 @@ function withProviderBaseUrl( } as OpenClawConfig; } -export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return xiaomiPresetAppliers.applyProviderConfig(cfg); -} - -export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { - return xiaomiPresetAppliers.applyConfig(cfg); -} - export function applyXiaomiTokenPlanConfig( cfg: OpenClawConfig, region: XiaomiTokenPlanRegion, diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 23c4e95b704d..f145418191e7 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -7,7 +7,9 @@ import { createSetupInputPresenceValidator, DEFAULT_ACCOUNT_ID, normalizeAccountId, + patchScopedAccountConfig, createSetupTranslator, + setSetupChannelEnabled, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; @@ -17,12 +19,6 @@ const t = createSetupTranslator(); const channel = "zalo" as const; -type ZaloAccountSetupConfig = { - enabled?: boolean; - dmPolicy?: string; - allowFrom?: Array | ReadonlyArray; -}; - export const zaloSetupAdapter = { ...createPatchedAccountSetupAdapter({ channelKey: channel, @@ -77,42 +73,8 @@ export const zaloDmPolicy = createChannelDmPolicy({ : resolveDefaultZaloAccountId(cfg); return resolveZaloAccount({ cfg, accountId: resolvedAccountId }); }, - applyPatch: ({ cfg, account, patch }) => { - if (account.accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - zalo: { - ...cfg.channels?.zalo, - enabled: true, - ...patch, - }, - }, - }; - } - const currentAccount = cfg.channels?.zalo?.accounts?.[account.accountId] as - | ZaloAccountSetupConfig - | undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - zalo: { - ...cfg.channels?.zalo, - enabled: true, - accounts: { - ...cfg.channels?.zalo?.accounts, - [account.accountId]: { - ...currentAccount, - enabled: currentAccount?.enabled ?? true, - ...patch, - }, - }, - }, - }, - }; - }, + applyPatch: ({ cfg, account, patch }) => + patchScopedAccountConfig({ cfg, channelKey: channel, accountId: account.accountId, patch }), promptAllowFrom: async ({ cfg, prompter, accountId }) => promptZaloAllowFrom({ cfg, @@ -138,15 +100,6 @@ export function createZaloSetupWizardProxy( credentials: [], delegateFinalize: true, dmPolicy: zaloDmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - zalo: { - ...cfg.channels?.zalo, - enabled: false, - }, - }, - }), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), }); } diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index 9be9fe393349..fe101fc9cf93 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -962,6 +962,17 @@ export function collectDatabaseFirstLegacyStoreViolations( return `${objectName}.${propertyName}`; } + function shadowObjectPropertyScopes(scopes, objectName, value, targetScope = lastScope(scopes)) { + const prefix = `${objectName}.`; + for (const scope of scopes) { + for (const name of scope.keys()) { + if (name.startsWith(prefix)) { + targetScope.set(name, value); + } + } + } + } + function resolveLegacyPathIdentifier(name) { return scopeForRead(legacyPathScopes, name)?.get(name) === true; } @@ -1598,35 +1609,15 @@ export function collectDatabaseFirstLegacyStoreViolations( if (resolveFsSafeStoreFactoryAlias(bindingName)) { lastScope(fsSafeStoreFactoryAliasScopes).set(bindingName, null); } - const prefix = `${bindingName}.`; - for (const scope of fsSafeStoreFactoryAliasScopes) { - for (const alias of scope.keys()) { - if (alias.startsWith(prefix)) { - lastScope(fsSafeStoreFactoryAliasScopes).set(alias, null); - } - } - } + shadowObjectPropertyScopes(fsSafeStoreFactoryAliasScopes, bindingName, null); if (resolveFsSafeStore(bindingName)) { lastScope(fsSafeStoreScopes).set(bindingName, false); } if (resolveFsSafeJsonStore(bindingName)) { lastScope(fsSafeJsonStoreScopes).set(bindingName, false); } - const storePrefix = `${bindingName}.`; - for (const scope of fsSafeStoreScopes) { - for (const alias of scope.keys()) { - if (alias.startsWith(storePrefix)) { - lastScope(fsSafeStoreScopes).set(alias, false); - } - } - } - for (const scope of fsSafeJsonStoreScopes) { - for (const alias of scope.keys()) { - if (alias.startsWith(storePrefix)) { - lastScope(fsSafeJsonStoreScopes).set(alias, false); - } - } - } + shadowObjectPropertyScopes(fsSafeStoreScopes, bindingName, false); + shadowObjectPropertyScopes(fsSafeJsonStoreScopes, bindingName, false); } } @@ -1707,24 +1698,11 @@ export function collectDatabaseFirstLegacyStoreViolations( } function clearFsWriteObjectAliases(scope, objectName) { - const prefix = `${objectName}.`; - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - scope.set(name, null); - } - } + shadowObjectPropertyScopes([scope], objectName, null, scope); } function shadowVisibleFsWriteObjectAliases(objectName) { - const prefix = `${objectName}.`; - const currentScope = lastScope(fsWriteAliasScopes); - for (const scope of fsWriteAliasScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentScope.set(name, null); - } - } - } + shadowObjectPropertyScopes(fsWriteAliasScopes, objectName, null); } function setFsWriteObjectAlias(scope, name, writeName, conditionalWrite) { @@ -1770,37 +1748,13 @@ export function collectDatabaseFirstLegacyStoreViolations( } function clearFsSafeStoreObjectAliases(storeScope, jsonStoreScope, objectName) { - const prefix = `${objectName}.`; - for (const name of storeScope.keys()) { - if (name.startsWith(prefix)) { - storeScope.set(name, false); - } - } - for (const name of jsonStoreScope.keys()) { - if (name.startsWith(prefix)) { - jsonStoreScope.set(name, false); - } - } + shadowObjectPropertyScopes([storeScope], objectName, false, storeScope); + shadowObjectPropertyScopes([jsonStoreScope], objectName, false, jsonStoreScope); } function shadowVisibleFsSafeStoreObjectAliases(objectName) { - const prefix = `${objectName}.`; - const currentStoreScope = lastScope(fsSafeStoreScopes); - const currentJsonStoreScope = lastScope(fsSafeJsonStoreScopes); - for (const scope of fsSafeStoreScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentStoreScope.set(name, false); - } - } - } - for (const scope of fsSafeJsonStoreScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentJsonStoreScope.set(name, false); - } - } - } + shadowObjectPropertyScopes(fsSafeStoreScopes, objectName, false); + shadowObjectPropertyScopes(fsSafeJsonStoreScopes, objectName, false); } function setFsSafeStoreObjectAlias( @@ -1929,13 +1883,8 @@ export function collectDatabaseFirstLegacyStoreViolations( } function clearFsModuleObjectProperties(scope, objectName) { - const prefix = `${objectName}.`; scope.set(objectName, false); - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - scope.set(name, false); - } - } + shadowObjectPropertyScopes([scope], objectName, false, scope); } function registerFsModuleObjectProperties( @@ -4043,23 +3992,11 @@ export function collectDatabaseFirstLegacyStoreViolations( } function resolveBodyFsWriteAlias(name) { - for (let index = bodyFsWriteAliasScopes.length - 1; index >= 0; index--) { - const scope = bodyFsWriteAliasScopes[index]; - if (scope.has(name)) { - return scope.get(name) ?? null; - } - } - return null; + return scopeForRead(bodyFsWriteAliasScopes, name)?.get(name) ?? null; } function resolveBodyFsModuleBinding(name) { - for (let index = bodyFsModuleBindingScopes.length - 1; index >= 0; index--) { - const scope = bodyFsModuleBindingScopes[index]; - if (scope.has(name)) { - return scope.get(name) === true; - } - } - return false; + return scopeForRead(bodyFsModuleBindingScopes, name)?.get(name) === true; } function resolveBodyFsModuleProperty(pathParts) { @@ -4080,12 +4017,7 @@ export function collectDatabaseFirstLegacyStoreViolations( } function isFsModuleShadowed(name) { - for (let index = fsModuleShadowScopes.length - 1; index >= 0; index--) { - if (fsModuleShadowScopes[index].has(name)) { - return true; - } - } - return false; + return fsModuleShadowScopes.some((scope) => scope.has(name)); } function isWrapperRequireName(name) { @@ -4139,34 +4071,15 @@ export function collectDatabaseFirstLegacyStoreViolations( } function resolveBodyRequireAlias(name) { - for (let index = bodyRequireAliasScopes.length - 1; index >= 0; index--) { - const scope = bodyRequireAliasScopes[index]; - if (scope.has(name)) { - return scope.get(name) === true; - } - } - return false; + return scopeForRead(bodyRequireAliasScopes, name)?.get(name) === true; } function shadowVisibleBodyFsWriteObjectAliases(objectName) { - const prefix = `${objectName}.`; - const currentScope = lastScope(bodyFsWriteAliasScopes); - for (const scope of bodyFsWriteAliasScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentScope.set(name, null); - } - } - } + shadowObjectPropertyScopes(bodyFsWriteAliasScopes, objectName, null); } function clearBodyFsWriteObjectAliases(scope, objectName) { - const prefix = `${objectName}.`; - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - scope.set(name, null); - } - } + shadowObjectPropertyScopes([scope], objectName, null, scope); } function setBodyFsWriteObjectAlias(scope, name, writeName) { @@ -4229,12 +4142,7 @@ export function collectDatabaseFirstLegacyStoreViolations( } function isFsAliasShadowed(name) { - for (let index = fsAliasShadowScopes.length - 1; index >= 0; index--) { - if (fsAliasShadowScopes[index].has(name)) { - return true; - } - } - return false; + return fsAliasShadowScopes.some((scope) => scope.has(name)); } function isWrapperFsModuleExpression(expression) { @@ -4490,24 +4398,11 @@ export function collectDatabaseFirstLegacyStoreViolations( } function clearNestedWrapperObjectMethods(scope, objectName) { - const prefix = `${objectName}.`; - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - scope.set(name, null); - } - } + shadowObjectPropertyScopes([scope], objectName, null, scope); } function shadowVisibleNestedWrapperObjectMethods(objectName) { - const prefix = `${objectName}.`; - const currentScope = lastScope(nestedWrapperFunctionScopes); - for (const scope of nestedWrapperFunctionScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentScope.set(name, null); - } - } - } + shadowObjectPropertyScopes(nestedWrapperFunctionScopes, objectName, null); } function markNestedWrapperObjectUnknown( @@ -6345,12 +6240,7 @@ export function collectDatabaseFirstLegacyStoreViolations( } function clearWrapperObjectMethods(scope, objectName) { - const prefix = `${objectName}.`; - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - scope.set(name, null); - } - } + shadowObjectPropertyScopes([scope], objectName, null, scope); } function clearWrapperObjectMethod(scope, methodName) { @@ -6359,15 +6249,7 @@ export function collectDatabaseFirstLegacyStoreViolations( } function shadowVisibleWrapperObjectMethods(objectName) { - const prefix = `${objectName}.`; - const currentScope = lastScope(wrapperFunctionScopes); - for (const scope of wrapperFunctionScopes) { - for (const name of scope.keys()) { - if (name.startsWith(prefix)) { - currentScope.set(name, null); - } - } - } + shadowObjectPropertyScopes(wrapperFunctionScopes, objectName, null); } function copyWrapperObjectMethods( diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index bf5908c68fa2..9dc11fdf696f 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -2067,102 +2067,75 @@ describe("subagent registry seam flow", () => { expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); }); - it("prefers explicit run timeout over late restored agent.wait success", async () => { - const startedAt = Date.parse("2026-03-24T11:59:00Z"); - vi.setSystemTime(startedAt + 61_000); - mocks.resolveAgentTimeoutMs.mockReturnValue(60_000); - mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: { - runs: Map; - mergeOnly?: boolean; - }) => { - params.runs.set( - "run-resumed-late-success", - createSubagentRunRecord({ - runId: "run-resumed-late-success", - task: "resume after explicit timeout", - runTimeoutSeconds: 60, - createdAt: startedAt, - startedAt, - sessionStartedAt: startedAt, - }), - ); - return 1; - }) as never); - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { - status: "ok", - startedAt, - endedAt: startedAt + 61_000, - }, - }); - - mod.initSubagentRegistry(); - - await waitForFast(() => { - const completedRun = findRequesterRun("run-resumed-late-success"); - expect(completedRun?.endedAt).toBe(startedAt + 60_000); - expectRecordFields( - completedRun?.outcome, - { - status: "timeout", - startedAt, - endedAt: startedAt + 60_000, - elapsedMs: 60_000, - }, - "late restored wait success timeout outcome", - ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); - - it("uses observed agent.wait start time when applying explicit run deadline", async () => { - const createdAt = Date.parse("2026-03-24T11:59:00Z"); - const observedStartedAt = createdAt + 10_000; - vi.setSystemTime(createdAt + 65_000); - mocks.resolveAgentTimeoutMs.mockReturnValue(60_000); - mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: { - runs: Map; - mergeOnly?: boolean; - }) => { - params.runs.set( - "run-resumed-observed-start", - createSubagentRunRecord({ - runId: "run-resumed-observed-start", - task: "respect observed start", - runTimeoutSeconds: 60, - createdAt, - startedAt: createdAt, - sessionStartedAt: createdAt, - }), - ); - return 1; - }) as never); - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { - status: "ok", - startedAt: observedStartedAt, - endedAt: createdAt + 65_000, - }, - }); - - mod.initSubagentRegistry(); - - await waitForFast(() => { - const completedRun = findRequesterRun("run-resumed-observed-start"); - expect(completedRun?.endedAt).toBe(createdAt + 65_000); - expectRecordFields( - completedRun?.outcome, - { + it.each([ + { + name: "prefers explicit run timeout over late restored agent.wait success", + runId: "run-resumed-late-success", + task: "resume after explicit timeout", + waitStartedAfterMs: 0, + waitEndedAfterMs: 61_000, + expected: { status: "timeout", startedAfterMs: 0, endedAfterMs: 60_000, elapsedMs: 60_000 }, + label: "late restored wait success timeout outcome", + }, + { + name: "uses observed agent.wait start time when applying explicit run deadline", + runId: "run-resumed-observed-start", + task: "respect observed start", + waitStartedAfterMs: 10_000, + waitEndedAfterMs: 65_000, + expected: { status: "ok", startedAfterMs: 10_000, endedAfterMs: 65_000, elapsedMs: 55_000 }, + label: "observed start success outcome", + }, + ] as const)( + "$name", + async ({ runId, task, waitStartedAfterMs, waitEndedAfterMs, expected, label }) => { + const createdAt = Date.parse("2026-03-24T11:59:00Z"); + vi.setSystemTime(createdAt + waitEndedAfterMs); + mocks.resolveAgentTimeoutMs.mockReturnValue(60_000); + mocks.restoreSubagentRunsFromDisk.mockImplementation(((params: { + runs: Map; + mergeOnly?: boolean; + }) => { + params.runs.set( + runId, + createSubagentRunRecord({ + runId, + task, + runTimeoutSeconds: 60, + createdAt, + startedAt: createdAt, + sessionStartedAt: createdAt, + }), + ); + return 1; + }) as never); + mockGatewayMethods(mocks.callGateway, { + "agent.wait": { status: "ok", - startedAt: observedStartedAt, - endedAt: createdAt + 65_000, - elapsedMs: 55_000, + startedAt: createdAt + waitStartedAfterMs, + endedAt: createdAt + waitEndedAfterMs, }, - "observed start success outcome", - ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); + }); + + mod.initSubagentRegistry(); + + await waitForFast(() => { + const completedRun = findRequesterRun(runId); + expect(completedRun?.endedAt).toBe(createdAt + expected.endedAfterMs); + expectRecordFields( + completedRun?.outcome, + { + status: expected.status, + startedAt: createdAt + expected.startedAfterMs, + endedAt: createdAt + expected.endedAfterMs, + elapsedMs: expected.elapsedMs, + }, + label, + ); + }); + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + }, + ); it("uses session-store start time for successful agent.wait results without a start", async () => { const createdAt = Date.parse("2026-03-24T11:59:00Z"); @@ -2210,119 +2183,103 @@ describe("subagent registry seam flow", () => { expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); }); - it("does not terminally time out plain agent.wait timeouts before the observed run deadline", async () => { - const createdAt = Date.parse("2026-03-24T11:59:00Z"); - const observedStartedAt = createdAt + 10_000; - vi.setSystemTime(createdAt + 61_000); - let waitAttempts = 0; - mocks.callGateway.mockImplementation(async (request: { method?: string }) => { - if (request.method === "agent.wait") { - waitAttempts += 1; - return { - status: "timeout", - startedAt: observedStartedAt, - }; - } - return {}; - }); - mocks.loadSessionStore.mockReturnValue( - createSessionStore({ - updatedAt: createdAt, - status: "running", - }), - ); - - mod.registerSubagentRun({ + it.each([ + { + name: "does not terminally time out plain agent.wait timeouts before the observed run deadline", runId: "run-plain-timeout-observed-start", task: "do not timeout before observed start deadline", - runTimeoutSeconds: 60, - }); - - let run = findRequesterRun("run-plain-timeout-observed-start"); - await waitForFast(() => { - expect(waitAttempts).toBeGreaterThanOrEqual(1); - run = findRequesterRun("run-plain-timeout-observed-start"); - expect(run?.endedAt).toBeUndefined(); - expect(run?.outcome).toBeUndefined(); - expect(run?.startedAt).toBe(observedStartedAt); - }); - - vi.setSystemTime(observedStartedAt + 60_000); - await vi.advanceTimersByTimeAsync(5_000); - - await waitForFast(() => { - run = findRequesterRun("run-plain-timeout-observed-start"); - expect(run?.endedAt).toBe(observedStartedAt + 60_000); - expectRecordFields( - run?.outcome, - { - status: "timeout", - startedAt: observedStartedAt, - endedAt: observedStartedAt + 60_000, - elapsedMs: 60_000, - }, - "observed start plain wait timeout outcome", - ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); - - it("uses running session-store start time for plain agent.wait timeouts", async () => { - const createdAt = Date.parse("2026-03-24T11:59:00Z"); - const sessionStartedAt = createdAt + 10_000; - vi.setSystemTime(createdAt); - let waitAttempts = 0; - mocks.callGateway.mockImplementation(async (request: { method?: string }) => { - if (request.method === "agent.wait") { - waitAttempts += 1; - if (waitAttempts === 1) { - vi.setSystemTime(createdAt + 61_000); - } - return { status: "timeout" }; - } - return {}; - }); - mocks.loadSessionStore.mockReturnValue( - createSessionStore({ - updatedAt: createdAt + 61_000, - status: "running", - startedAt: sessionStartedAt, - }), - ); - - mod.registerSubagentRun({ + initialNowAfterMs: 61_000, + waitStartedAfterMs: 10_000, + sessionStartedAfterMs: undefined, + observedStartedAfterMs: 10_000, + sessionUpdatedAfterMs: 0, + advanceOnFirstWait: false, + label: "observed start plain wait timeout outcome", + }, + { + name: "uses running session-store start time for plain agent.wait timeouts", runId: "run-plain-timeout-session-store-start", task: "do not timeout before session store start deadline", - runTimeoutSeconds: 60, - }); - - await waitForFast(() => { - const run = findRequesterRun("run-plain-timeout-session-store-start"); - expect(waitAttempts).toBeGreaterThanOrEqual(1); - expect(run?.endedAt).toBeUndefined(); - expect(run?.outcome).toBeUndefined(); - expect(run?.startedAt).toBe(sessionStartedAt); - }); - - vi.setSystemTime(sessionStartedAt + 60_000); - await vi.advanceTimersByTimeAsync(5_000); - - await waitForFast(() => { - const run = findRequesterRun("run-plain-timeout-session-store-start"); - expect(run?.endedAt).toBe(sessionStartedAt + 60_000); - expectRecordFields( - run?.outcome, - { + initialNowAfterMs: 0, + waitStartedAfterMs: undefined, + sessionStartedAfterMs: 10_000, + observedStartedAfterMs: 10_000, + sessionUpdatedAfterMs: 61_000, + advanceOnFirstWait: true, + label: "session store start plain wait timeout outcome", + }, + ] as const)( + "$name", + async ({ + runId, + task, + initialNowAfterMs, + waitStartedAfterMs, + sessionStartedAfterMs, + observedStartedAfterMs, + sessionUpdatedAfterMs, + advanceOnFirstWait, + label, + }) => { + const createdAt = Date.parse("2026-03-24T11:59:00Z"); + const observedStartedAt = createdAt + observedStartedAfterMs; + vi.setSystemTime(createdAt + initialNowAfterMs); + let waitAttempts = 0; + mocks.callGateway.mockImplementation(async (request: { method?: string }) => { + if (request.method !== "agent.wait") { + return {}; + } + waitAttempts += 1; + if (advanceOnFirstWait && waitAttempts === 1) { + vi.setSystemTime(createdAt + 61_000); + } + return { status: "timeout", - startedAt: sessionStartedAt, - endedAt: sessionStartedAt + 60_000, - elapsedMs: 60_000, - }, - "session store start plain wait timeout outcome", + ...(waitStartedAfterMs === undefined + ? {} + : { startedAt: createdAt + waitStartedAfterMs }), + }; + }); + mocks.loadSessionStore.mockReturnValue( + createSessionStore({ + status: "running", + updatedAt: createdAt + sessionUpdatedAfterMs, + ...(sessionStartedAfterMs === undefined + ? {} + : { startedAt: createdAt + sessionStartedAfterMs }), + }), ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); + + mod.registerSubagentRun({ runId, task, runTimeoutSeconds: 60 }); + + await waitForFast(() => { + const run = findRequesterRun(runId); + expect(waitAttempts).toBeGreaterThanOrEqual(1); + expect(run?.endedAt).toBeUndefined(); + expect(run?.outcome).toBeUndefined(); + expect(run?.startedAt).toBe(observedStartedAt); + }); + + vi.setSystemTime(observedStartedAt + 60_000); + await vi.advanceTimersByTimeAsync(5_000); + + await waitForFast(() => { + const run = findRequesterRun(runId); + expect(run?.endedAt).toBe(observedStartedAt + 60_000); + expectRecordFields( + run?.outcome, + { + status: "timeout", + startedAt: observedStartedAt, + endedAt: observedStartedAt + 60_000, + elapsedMs: 60_000, + }, + label, + ); + }); + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + }, + ); it.each([ { @@ -2516,87 +2473,74 @@ describe("subagent registry seam flow", () => { // this test pins the audit-relevant ordering (outcome + endedAt) only. }); - it("caps terminal agent.wait timeouts to the explicit run deadline", async () => { - const startedAt = Date.now(); - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { - status: "timeout", - startedAt, - endedAt: startedAt + 2_000, - stopReason: "rpc", - }, - }); - mocks.loadSessionStore.mockReturnValue( - createSessionStore({ - updatedAt: startedAt, - status: "running", - }), - ); - - mod.registerSubagentRun({ + it.each([ + { + name: "caps terminal agent.wait timeouts to the explicit run deadline", runId: "run-terminal-timeout-capped", task: "cap terminal timeout", + initialNowAfterMs: 60_000, + waitStartedAfterMs: 60_000, + waitEndedAfterMs: 62_000, + sessionUpdatedAfterMs: 60_000, runTimeoutSeconds: 1, - }); - - await waitForFast(() => { - const run = findRequesterRun("run-terminal-timeout-capped"); - expect(run?.endedAt).toBe(startedAt + 1_000); - expectRecordFields( - run?.outcome, - { - status: "timeout", - startedAt, - endedAt: startedAt + 1_000, - elapsedMs: 1_000, - }, - "capped terminal timeout outcome", - ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); - - it("uses observed agent.wait start time when capping terminal timeout", async () => { - const createdAt = Date.parse("2026-03-24T11:59:00Z"); - const observedStartedAt = createdAt + 10_000; - vi.setSystemTime(createdAt + 75_000); - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { - status: "timeout", - startedAt: observedStartedAt, - endedAt: createdAt + 75_000, - stopReason: "rpc", - }, - }); - mocks.loadSessionStore.mockReturnValue( - createSessionStore({ - updatedAt: createdAt, - status: "running", - }), - ); - - mod.registerSubagentRun({ + label: "capped terminal timeout outcome", + }, + { + name: "uses observed agent.wait start time when capping terminal timeout", runId: "run-terminal-timeout-observed-start", task: "cap timeout using observed start", + initialNowAfterMs: 75_000, + waitStartedAfterMs: 10_000, + waitEndedAfterMs: 75_000, + sessionUpdatedAfterMs: 0, runTimeoutSeconds: 60, - }); - - await waitForFast(() => { - const run = findRequesterRun("run-terminal-timeout-observed-start"); - expect(run?.endedAt).toBe(observedStartedAt + 60_000); - expectRecordFields( - run?.outcome, - { + label: "observed start capped terminal timeout outcome", + }, + ] as const)( + "$name", + async ({ + runId, + task, + initialNowAfterMs, + waitStartedAfterMs, + waitEndedAfterMs, + sessionUpdatedAfterMs, + runTimeoutSeconds, + label, + }) => { + const createdAt = Date.parse("2026-03-24T11:59:00Z"); + const startedAt = createdAt + waitStartedAfterMs; + const elapsedMs = runTimeoutSeconds * 1_000; + vi.setSystemTime(createdAt + initialNowAfterMs); + mockGatewayMethods(mocks.callGateway, { + "agent.wait": { status: "timeout", - startedAt: observedStartedAt, - endedAt: observedStartedAt + 60_000, - elapsedMs: 60_000, + startedAt, + endedAt: createdAt + waitEndedAfterMs, + stopReason: "rpc", }, - "observed start capped terminal timeout outcome", + }); + mocks.loadSessionStore.mockReturnValue( + createSessionStore({ + updatedAt: createdAt + sessionUpdatedAfterMs, + status: "running", + }), ); - }); - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); + + mod.registerSubagentRun({ runId, task, runTimeoutSeconds }); + + await waitForFast(() => { + const run = findRequesterRun(runId); + expect(run?.endedAt).toBe(startedAt + elapsedMs); + expectRecordFields( + run?.outcome, + { status: "timeout", startedAt, endedAt: startedAt + elapsedMs, elapsedMs }, + label, + ); + }); + expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); + }, + ); it("ignores stale terminal session-store rows from older child runs", async () => { let waitAttempts = 0; @@ -2943,89 +2887,63 @@ describe("subagent registry seam flow", () => { expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled(); }); - it("announces blocked agent.wait snapshots as errors instead of success", async () => { - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { - status: "ok", - startedAt: 100, - endedAt: 250, - livenessState: "blocked", - error: "Context overflow: prompt too large for the model.", - }, - }); - - mod.registerSubagentRun({ + it.each([ + { + name: "announces blocked agent.wait snapshots as errors instead of success", runId: "run-blocked-wait", task: "overflow wait", - expectsCompletionMessage: true, - }); - - await waitForFast(() => { - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); - const announceParams = expectRecordFields( - getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "blocked wait announce"), - { childRunId: "run-blocked-wait" }, - "blocked wait announce params", - ); - expectRecordFields( - announceParams.outcome, - { + wait: { + status: "ok", + error: "Context overflow: prompt too large for the model.", + }, + expectedOutcome: { status: "error", error: "Context overflow: prompt too large for the model.", - startedAt: 100, - endedAt: 250, - elapsedMs: 150, }, - "blocked wait announce outcome", - ); - - const run = findRequesterRun("run-blocked-wait"); - expect(run?.endedReason).toBe("subagent-error"); - expect(run?.outcome?.status).toBe("error"); - }); - - it("announces provider hard timeout wait snapshots as timeouts despite blocked metadata", async () => { - mockGatewayMethods(mocks.callGateway, { - "agent.wait": { + expectedReason: "subagent-error", + label: "blocked wait announce", + }, + { + name: "announces provider hard timeout wait snapshots as timeouts despite blocked metadata", + runId: "run-blocked-hard-timeout-wait", + task: "provider timeout wait", + wait: { status: "error", - startedAt: 100, - endedAt: 250, - livenessState: "blocked", timeoutPhase: "provider", providerStarted: true, error: "model timed out", }, + expectedOutcome: { status: "timeout" }, + expectedReason: "subagent-complete", + label: "hard timeout wait announce", + }, + ] as const)("$name", async ({ runId, task, wait, expectedOutcome, expectedReason, label }) => { + mockGatewayMethods(mocks.callGateway, { + "agent.wait": { + startedAt: 100, + endedAt: 250, + livenessState: "blocked", + ...wait, + }, }); - mod.registerSubagentRun({ - runId: "run-blocked-hard-timeout-wait", - task: "provider timeout wait", - expectsCompletionMessage: true, - }); + mod.registerSubagentRun({ runId, task, expectsCompletionMessage: true }); - await waitForFast(() => { - expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); - }); + await waitForFast(() => expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1)); const announceParams = expectRecordFields( - getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "hard timeout wait announce"), - { childRunId: "run-blocked-hard-timeout-wait" }, - "hard timeout wait announce params", + getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, label), + { childRunId: runId }, + `${label} params`, ); expectRecordFields( announceParams.outcome, - { - status: "timeout", - startedAt: 100, - endedAt: 250, - elapsedMs: 150, - }, - "hard timeout wait announce outcome", + { ...expectedOutcome, startedAt: 100, endedAt: 250, elapsedMs: 150 }, + `${label} outcome`, ); - const run = findRequesterRun("run-blocked-hard-timeout-wait"); - expect(run?.endedReason).toBe("subagent-complete"); - expect(run?.outcome?.status).toBe("timeout"); + const run = findRequesterRun(runId); + expect(run?.endedReason).toBe(expectedReason); + expect(run?.outcome?.status).toBe(expectedOutcome.status); }); it("publishes aborted agent.wait snapshots only after killed reconciliation", async () => { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index edd4169b2fca..446e9faeb92d 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -4259,116 +4259,6 @@ describe("persistSessionUsageUpdate", () => { await writeSessionStoreFast(storePath, { [sessionKey]: entry }); } - it("uses lastCallUsage for totalTokens when provided", async () => { - const storePath = await createStorePath("openclaw-usage-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens: 100_000, - }); - - const accumulatedUsage = { input: 180_000, output: 10_000, total: 190_000 }; - const lastCallUsage = { input: 12_000, output: 2_000, total: 14_000 }; - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: accumulatedUsage, - lastCallUsage, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 12_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe( - 180_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens, - ).toBe(10_000); - }); - - it("keeps the prior total stale when last-call context is unavailable", async () => { - const storePath = await createStorePath("openclaw-usage-unavailable-context-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens: 148_874, - totalTokensFresh: true, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 12, output: 15_104, cacheRead: 819_661, cacheWrite: 93_130 }, - lastCallUsage: { - input: 12, - output: 15_104, - cacheRead: 819_661, - cacheWrite: 93_130, - contextUsage: { state: "unavailable" }, - total: 927_907, - }, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 148_874, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(false); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe( - 12, - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe( - 819_661, - ); - }); - - it.each([ - { - name: "marks a fresh zero stale when a completed run has no context snapshot", - totalTokens: 0, - preserve: false, - expectedFresh: false, - }, - { - name: "preserves fresh post-compaction totalTokens across model-only updates", - totalTokens: 42_000, - preserve: true, - expectedFresh: true, - }, - ])("$name", async ({ totalTokens, preserve, expectedFresh }) => { - const storePath = await createStorePath("openclaw-usage-no-snapshot-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens, - totalTokensFresh: true, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - modelUsed: "claude-sonnet-4-6", - contextTokensUsed: 200_000, - preserveFreshTotalTokensOnStaleUsage: preserve, - }); - - const stored = expectDefined(readSessionStoreFast(storePath)[sessionKey], "stored session"); - expect(stored.totalTokens).toBe(totalTokens); - expect(stored.totalTokensFresh).toBe(expectedFresh); - }); it("accounts exhausted-run usage without committing its model and persists CLI binding", async () => { const storePath = await createStorePath("openclaw-usage-exhausted-"); const sessionKey = "main"; @@ -4470,6 +4360,218 @@ describe("persistSessionUsageUpdate", () => { }); it.each([ + { + name: "treats CLI usage as a fresh context snapshot when requested", + seed: {}, + update: { + usage: { input: 24_000, output: 2_000, cacheRead: 8_000 }, + usageIsContextSnapshot: true, + providerUsed: "claude-cli", + cliSessionBinding: { + sessionId: "cli-session-1", + authProfileId: "anthropic:default", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-hash", + }, + }, + expected: { + totalTokens: 32_000, + totalTokensFresh: true, + cliSessionIds: { "claude-cli": "cli-session-1" }, + cliSessionBindings: { + "claude-cli": { + sessionId: "cli-session-1", + authProfileId: "anthropic:default", + extraSystemPromptHash: "prompt-hash", + mcpConfigHash: "mcp-hash", + }, + }, + }, + }, + { + name: "clears stale CLI binding when usage update reports an unflushed replacement", + seed: { + cliSessionIds: { "claude-cli": "stale-cli-session", "codex-cli": "codex-session" }, + cliSessionBindings: { + "claude-cli": { sessionId: "stale-cli-session", authProfileId: "anthropic:old" }, + "codex-cli": { sessionId: "codex-session" }, + }, + claudeCliSessionId: "stale-cli-session", + }, + update: { + usage: { input: 24_000, output: 2_000, cacheRead: 8_000 }, + usageIsContextSnapshot: true, + providerUsed: "claude-cli", + clearCliSessionBinding: true, + }, + expected: { + cliSessionIds: { "codex-cli": "codex-session" }, + cliSessionBindings: { "codex-cli": { sessionId: "codex-session" } }, + claudeCliSessionId: undefined, + }, + }, + { + name: "prefers fresh final usage over zero compactionTokensAfter", + seed: { + totalTokens: 1_794_391, + totalTokensFresh: true, + inputTokens: 20, + outputTokens: 10_855, + cacheRead: 1_761_324, + cacheWrite: 33_047, + }, + update: { + usage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 }, + lastCallUsage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 }, + usageIsContextSnapshot: true, + providerUsed: "claude-cli", + contextTokensUsed: 1_048_576, + compactionTokensAfter: 0, + }, + expected: { + totalTokens: 1_794_391, + totalTokensFresh: true, + inputTokens: 20, + outputTokens: 10_855, + cacheRead: 1_761_324, + cacheWrite: 33_047, + }, + }, + { + name: "prefers fresh lastCallUsage over positive compactionTokensAfter", + seed: { totalTokens: 180_000, totalTokensFresh: true }, + update: { + usage: { input: 100_000, output: 3_000, cacheRead: 20_000 }, + lastCallUsage: { input: 91_000, output: 1_000, cacheRead: 4_000 }, + providerUsed: "openai", + compactionTokensAfter: 80_000, + }, + expected: { + totalTokens: 95_000, + totalTokensFresh: true, + inputTokens: 100_000, + outputTokens: 3_000, + cacheRead: 4_000, + }, + }, + { + name: "uses positive compactionTokensAfter when final usage has no prompt total", + seed: { + totalTokens: 180_000, + totalTokensFresh: true, + inputTokens: 5_000, + outputTokens: 2_000, + cacheRead: 50_000, + contextBudgetStatus: { + schemaVersion: 1, + source: "pre-prompt-estimate", + updatedAt: 1, + provider: "claude-cli", + model: "claude-opus-4-7", + route: "compact_only", + shouldCompact: true, + estimatedPromptTokens: 180_000, + contextTokenBudget: 1_048_576, + promptBudgetBeforeReserve: 1_044_480, + reserveTokens: 4_096, + effectiveReserveTokens: 4_096, + remainingPromptBudgetTokens: 864_480, + overflowTokens: 0, + toolResultReducibleChars: 0, + messageCount: 0, + unwindowedMessageCount: 0, + }, + }, + update: { + usage: { output: 125 }, + lastCallUsage: { output: 125 }, + providerUsed: "claude-cli", + contextTokensUsed: undefined, + compactionTokensAfter: 80_000, + }, + expected: { + totalTokens: 80_000, + totalTokensFresh: true, + inputTokens: undefined, + outputTokens: undefined, + cacheRead: undefined, + contextBudgetStatus: undefined, + }, + }, + { + name: "persists totalTokens from promptTokens when usage is unavailable", + seed: { inputTokens: 1_234, outputTokens: 456 }, + update: { usage: undefined, promptTokens: 39_000 }, + expected: { + totalTokens: 39_000, + totalTokensFresh: true, + inputTokens: 1_234, + outputTokens: 456, + }, + }, + { + name: "keeps non-clamped lastCallUsage totalTokens when exceeding context window", + seed: {}, + update: { + usage: { input: 300_000, output: 10_000, total: 310_000 }, + lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, + contextTokensUsed: 200_000, + }, + expected: { totalTokens: 250_000, totalTokensFresh: true }, + }, + { + name: "uses lastCallUsage for totalTokens when provided", + seed: { totalTokens: 100_000 }, + update: { + usage: { input: 180_000, output: 10_000, total: 190_000 }, + lastCallUsage: { input: 12_000, output: 2_000, total: 14_000 }, + }, + expected: { + totalTokens: 12_000, + totalTokensFresh: true, + inputTokens: 180_000, + outputTokens: 10_000, + }, + }, + { + name: "keeps the prior total stale when last-call context is unavailable", + seed: { totalTokens: 148_874, totalTokensFresh: true }, + update: { + usage: { input: 12, output: 15_104, cacheRead: 819_661, cacheWrite: 93_130 }, + lastCallUsage: { + input: 12, + output: 15_104, + cacheRead: 819_661, + cacheWrite: 93_130, + contextUsage: { state: "unavailable" }, + total: 927_907, + }, + }, + expected: { + totalTokens: 148_874, + totalTokensFresh: false, + inputTokens: 12, + cacheRead: 819_661, + }, + }, + { + name: "marks a fresh zero stale when a completed run has no context snapshot", + seed: { totalTokens: 0, totalTokensFresh: true }, + update: { + modelUsed: "claude-sonnet-4-6", + preserveFreshTotalTokensOnStaleUsage: false, + }, + expected: { totalTokens: 0, totalTokensFresh: false }, + }, + { + name: "preserves fresh post-compaction totalTokens across model-only updates", + seed: { totalTokens: 42_000, totalTokensFresh: true }, + update: { + modelUsed: "claude-sonnet-4-6", + preserveFreshTotalTokensOnStaleUsage: true, + }, + expected: { totalTokens: 42_000, totalTokensFresh: true }, + }, { name: "uses lastCallUsage cache counters when available", seed: {}, @@ -4506,7 +4608,12 @@ describe("persistSessionUsageUpdate", () => { update: { usage: { input: 50_000, output: 5_000, total: 55_000 }, promptTokens: 42_000 }, expected: { totalTokens: 42_000, totalTokensFresh: true }, }, - ])("$name", async ({ seed, update, expected, name }) => { + ] satisfies Array<{ + name: string; + seed: Partial; + update: Omit[0], "storePath" | "sessionKey">; + expected: Partial; + }>)("$name", async ({ seed, update, expected, name }) => { const storePath = await createStorePath("openclaw-usage-"); const sessionKey = "main"; await seedSessionStore(storePath, sessionKey, { @@ -4528,311 +4635,6 @@ describe("persistSessionUsageUpdate", () => { name, ); }); - it("treats CLI usage as a fresh context snapshot when requested", async () => { - const storePath = await createStorePath("openclaw-usage-cli-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { sessionId: "s1", updatedAt: Date.now() }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 24_000, output: 2_000, cacheRead: 8_000 }, - usageIsContextSnapshot: true, - providerUsed: "claude-cli", - cliSessionBinding: { - sessionId: "cli-session-1", - authProfileId: "anthropic:default", - extraSystemPromptHash: "prompt-hash", - mcpConfigHash: "mcp-hash", - }, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 32_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[ - "claude-cli" - ], - ).toBe("cli-session-1"); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[ - "claude-cli" - ], - ).toEqual({ - sessionId: "cli-session-1", - authProfileId: "anthropic:default", - extraSystemPromptHash: "prompt-hash", - mcpConfigHash: "mcp-hash", - }); - }); - - it("clears stale CLI binding when usage update reports an unflushed replacement", async () => { - const storePath = await createStorePath("openclaw-usage-cli-clear-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - cliSessionIds: { - "claude-cli": "stale-cli-session", - "codex-cli": "codex-session", - }, - cliSessionBindings: { - "claude-cli": { - sessionId: "stale-cli-session", - authProfileId: "anthropic:old", - }, - "codex-cli": { - sessionId: "codex-session", - }, - }, - claudeCliSessionId: "stale-cli-session", - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 24_000, output: 2_000, cacheRead: 8_000 }, - usageIsContextSnapshot: true, - providerUsed: "claude-cli", - clearCliSessionBinding: true, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[ - "claude-cli" - ], - ).toBeUndefined(); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionIds?.[ - "codex-cli" - ], - ).toBe("codex-session"); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[ - "claude-cli" - ], - ).toBeUndefined(); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cliSessionBindings?.[ - "codex-cli" - ], - ).toEqual({ - sessionId: "codex-session", - }); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").claudeCliSessionId, - ).toBeUndefined(); - }); - - it("prefers fresh final usage over zero compactionTokensAfter", async () => { - const storePath = await createStorePath("openclaw-usage-compaction-reset-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens: 1_794_391, - totalTokensFresh: true, - inputTokens: 20, - outputTokens: 10_855, - cacheRead: 1_761_324, - cacheWrite: 33_047, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 }, - lastCallUsage: { input: 20, output: 10_855, cacheRead: 1_761_324, cacheWrite: 33_047 }, - usageIsContextSnapshot: true, - providerUsed: "claude-cli", - contextTokensUsed: 1_048_576, - compactionTokensAfter: 0, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 1_794_391, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe( - 20, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens, - ).toBe(10_855); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe( - 1_761_324, - ); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheWrite).toBe( - 33_047, - ); - }); - - it("prefers fresh lastCallUsage over positive compactionTokensAfter", async () => { - const storePath = await createStorePath("openclaw-usage-compaction-positive-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens: 180_000, - totalTokensFresh: true, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 100_000, output: 3_000, cacheRead: 20_000 }, - lastCallUsage: { input: 91_000, output: 1_000, cacheRead: 4_000 }, - providerUsed: "openai", - contextTokensUsed: 200_000, - compactionTokensAfter: 80_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 95_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe( - 100_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens, - ).toBe(3_000); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead).toBe( - 4_000, - ); - }); - - it("uses positive compactionTokensAfter when final usage has no prompt total", async () => { - const storePath = await createStorePath("openclaw-usage-compaction-fallback-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - totalTokens: 180_000, - totalTokensFresh: true, - inputTokens: 5_000, - outputTokens: 2_000, - cacheRead: 50_000, - contextBudgetStatus: { - schemaVersion: 1, - source: "pre-prompt-estimate", - updatedAt: 1, - provider: "claude-cli", - model: "claude-opus-4-7", - route: "compact_only", - shouldCompact: true, - estimatedPromptTokens: 180_000, - contextTokenBudget: 1_048_576, - promptBudgetBeforeReserve: 1_044_480, - reserveTokens: 4_096, - effectiveReserveTokens: 4_096, - remainingPromptBudgetTokens: 864_480, - overflowTokens: 0, - toolResultReducibleChars: 0, - messageCount: 0, - unwindowedMessageCount: 0, - }, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { output: 125 }, - lastCallUsage: { output: 125 }, - providerUsed: "claude-cli", - compactionTokensAfter: 80_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 80_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens, - ).toBeUndefined(); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens, - ).toBeUndefined(); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").cacheRead, - ).toBeUndefined(); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").contextBudgetStatus, - ).toBeUndefined(); - }); - - it("persists totalTokens from promptTokens when usage is unavailable", async () => { - const storePath = await createStorePath("openclaw-usage-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { - sessionId: "s1", - updatedAt: Date.now(), - inputTokens: 1_234, - outputTokens: 456, - }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: undefined, - promptTokens: 39_000, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 39_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").inputTokens).toBe( - 1_234, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").outputTokens, - ).toBe(456); - }); - - it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => { - const storePath = await createStorePath("openclaw-usage-"); - const sessionKey = "main"; - await seedSessionStore(storePath, sessionKey, { sessionId: "s1", updatedAt: Date.now() }); - - await persistSessionUsageUpdate({ - storePath, - sessionKey, - usage: { input: 300_000, output: 10_000, total: 310_000 }, - lastCallUsage: { input: 250_000, output: 5_000, total: 255_000 }, - contextTokensUsed: 200_000, - }); - - const stored = readSessionStoreFast(storePath); - expect(expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokens).toBe( - 250_000, - ); - expect( - expectDefined(stored[sessionKey], "stored[sessionKey] test invariant").totalTokensFresh, - ).toBe(true); - }); - it("snapshots estimatedCostUsd instead of accumulating (fixes #69347)", async () => { const storePath = await createStorePath("openclaw-usage-cost-"); const sessionKey = "main"; diff --git a/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts b/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts index b7e10f4e9874..8fb239b3d010 100644 --- a/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts +++ b/src/channels/plugins/contracts/test-helpers/registry-session-binding.ts @@ -263,22 +263,84 @@ async function prepareTelegramSessionBindingContract() { await api.resetTelegramThreadBindingsForTests(); } -const sessionBindingContractEntries: Record< - SessionBindingContractChannelId, - Omit -> = { - discord: { +type SessionBindingContractFixture = { + id: SessionBindingContractChannelId; + accountId: string; + conversationId: string; + parentConversationId?: string; + targetSessionKey: string; + targetKind: SessionBindingRecord["targetKind"]; + label: string; + placements: SessionBindingCapabilities["placements"]; + preload: () => Promise; + beforeEach: () => Promise; + ensureManager: () => Promise; + stopManager?: () => Promise; +}; + +function createSessionBindingContractEntry( + fixture: SessionBindingContractFixture, +): Omit { + const conversation = { + channel: fixture.id, + accountId: fixture.accountId, + conversationId: fixture.conversationId, + ...(fixture.parentConversationId ? { parentConversationId: fixture.parentConversationId } : {}), + }; + + return { preload: async () => { - await getContractApi("discord"); + await fixture.preload(); }, + beforeEach: fixture.beforeEach, + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: fixture.placements, + }, + getCapabilities: async () => { + await fixture.ensureManager(); + return getSessionBindingService().getCapabilities({ + channel: fixture.id, + accountId: fixture.accountId, + }); + }, + bindAndResolve: async () => { + await fixture.ensureManager(); + const binding = await getSessionBindingService().bind({ + targetSessionKey: fixture.targetSessionKey, + targetKind: fixture.targetKind, + conversation, + placement: "current", + metadata: { agentId: fixture.id, label: fixture.label }, + }); + expectResolvedSessionBinding({ + ...conversation, + targetSessionKey: fixture.targetSessionKey, + }); + return binding; + }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, + cleanup: async () => { + await fixture.stopManager?.(); + expectClearedSessionBinding(conversation); + }, + }; +} + +const sessionBindingContractEntries = { + discord: createSessionBindingContractEntry({ + id: "discord", + accountId: "default", + conversationId: "channel:123456789012345678", + targetSessionKey: "agent:discord:child:thread-1", + targetKind: "subagent", + label: "discord-child", + placements: ["current", "child"], + preload: () => getContractApi("discord"), beforeEach: prepareDiscordSessionBindingContract, - expectedCapabilities: { - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }, - getCapabilities: async () => { + ensureManager: async () => { const { createThreadBindingManager } = await getContractApi("discord"); createThreadBindingManager({ accountId: "default", @@ -286,247 +348,80 @@ const sessionBindingContractEntries: Record< persist: false, enableSweeper: false, }); - return getSessionBindingService().getCapabilities({ - channel: "discord", - accountId: "default", - }); - }, - bindAndResolve: async () => { - const { createThreadBindingManager } = await getContractApi("discord"); - createThreadBindingManager({ - accountId: "default", - cfg: baseSessionBindingCfg, - persist: false, - enableSweeper: false, - }); - const service = getSessionBindingService(); - const binding = await service.bind({ - targetSessionKey: "agent:discord:child:thread-1", - targetKind: "subagent", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "channel:123456789012345678", - }, - placement: "current", - metadata: { - agentId: "discord", - label: "discord-child", - }, - }); - expectResolvedSessionBinding({ - channel: "discord", - accountId: "default", - conversationId: "channel:123456789012345678", - targetSessionKey: "agent:discord:child:thread-1", - }); - return binding; - }, - unbindAndVerify: unbindAndExpectClearedSessionBinding, - cleanup: async () => { - expectClearedSessionBinding({ - channel: "discord", - accountId: "default", - conversationId: "channel:123456789012345678", - }); - }, - }, - feishu: { - preload: async () => { - await getContractApi("feishu"); }, + }), + feishu: createSessionBindingContractEntry({ + id: "feishu", + accountId: "default", + conversationId: "oc_group_chat:topic:om_topic_root", + parentConversationId: "oc_group_chat", + targetSessionKey: "agent:feishu:child:thread-1", + targetKind: "subagent", + label: "feishu-child", + placements: ["current"], + preload: () => getContractApi("feishu"), beforeEach: prepareFeishuSessionBindingContract, - expectedCapabilities: { - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current"], - }, - getCapabilities: async () => { + ensureManager: async () => { const { createFeishuThreadBindingManager } = await getContractApi("feishu"); createFeishuThreadBindingManager({ accountId: "default", cfg: baseSessionBindingCfg, }); - return getSessionBindingService().getCapabilities({ - channel: "feishu", - accountId: "default", - }); - }, - bindAndResolve: async () => { - const { createFeishuThreadBindingManager } = - await getContractApi("feishu"); - createFeishuThreadBindingManager({ - accountId: "default", - cfg: baseSessionBindingCfg, - }); - const service = getSessionBindingService(); - const binding = await service.bind({ - targetSessionKey: "agent:feishu:child:thread-1", - targetKind: "subagent", - conversation: { - channel: "feishu", - accountId: "default", - conversationId: "oc_group_chat:topic:om_topic_root", - parentConversationId: "oc_group_chat", - }, - placement: "current", - metadata: { - agentId: "feishu", - label: "feishu-child", - }, - }); - expectResolvedSessionBinding({ - channel: "feishu", - accountId: "default", - conversationId: "oc_group_chat:topic:om_topic_root", - parentConversationId: "oc_group_chat", - targetSessionKey: "agent:feishu:child:thread-1", - }); - return binding; - }, - unbindAndVerify: unbindAndExpectClearedSessionBinding, - cleanup: async () => { - expectClearedSessionBinding({ - channel: "feishu", - accountId: "default", - conversationId: "oc_group_chat:topic:om_topic_root", - }); - }, - }, - imessage: { - preload: async () => { - await getContractApi("imessage"); }, + }), + imessage: createSessionBindingContractEntry({ + id: "imessage", + accountId: "default", + conversationId: "+15555550124", + targetSessionKey: "agent:imessage:current", + targetKind: "session", + label: "imessage-main", + placements: ["current"], + preload: () => getContractApi("imessage"), beforeEach: prepareIMessageSessionBindingContract, - expectedCapabilities: { - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current"], - }, - getCapabilities: () => { - void createContractChannelConversationBindingManager({ - channelId: "imessage", - cfg: baseSessionBindingCfg, - accountId: "default", - }); - return getSessionBindingService().getCapabilities({ - channel: "imessage", - accountId: "default", - }); - }, - bindAndResolve: async () => { + ensureManager: async () => { await createContractChannelConversationBindingManager({ channelId: "imessage", cfg: baseSessionBindingCfg, accountId: "default", }); - const service = getSessionBindingService(); - const binding = await service.bind({ - targetSessionKey: "agent:imessage:current", - targetKind: "session", - conversation: { - channel: "imessage", - accountId: "default", - conversationId: "+15555550124", - }, - placement: "current", - metadata: { - agentId: "imessage", - label: "imessage-main", - }, - }); - expectResolvedSessionBinding({ - channel: "imessage", - accountId: "default", - conversationId: "+15555550124", - targetSessionKey: "agent:imessage:current", - }); - return binding; }, - unbindAndVerify: unbindAndExpectClearedSessionBinding, - cleanup: async () => { + stopManager: async () => { const manager = await createContractChannelConversationBindingManager({ channelId: "imessage", cfg: baseSessionBindingCfg, accountId: "default", }); await manager?.stop(); - expectClearedSessionBinding({ - channel: "imessage", - accountId: "default", - conversationId: "+15555550124", - }); - }, - }, - matrix: { - preload: async () => { - await getContractApi("matrix"); }, + }), + matrix: createSessionBindingContractEntry({ + id: "matrix", + accountId: matrixSessionBindingAuth.accountId, + conversationId: "$thread", + parentConversationId: "!room:example.org", + targetSessionKey: "agent:matrix:thread", + targetKind: "subagent", + label: "matrix-thread", + placements: ["current", "child"], + preload: () => getContractApi("matrix"), beforeEach: prepareMatrixSessionBindingContract, - expectedCapabilities: { - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }, - getCapabilities: async () => { + ensureManager: async () => { await createContractMatrixThreadBindingManager(); - return getSessionBindingService().getCapabilities({ - channel: "matrix", - accountId: matrixSessionBindingAuth.accountId, - }); - }, - bindAndResolve: async () => { - await createContractMatrixThreadBindingManager(); - const service = getSessionBindingService(); - const binding = await service.bind({ - targetSessionKey: "agent:matrix:thread", - targetKind: "subagent", - conversation: { - channel: "matrix", - accountId: matrixSessionBindingAuth.accountId, - conversationId: "$thread", - parentConversationId: "!room:example.org", - }, - placement: "current", - metadata: { - agentId: "matrix", - label: "matrix-thread", - }, - }); - expectResolvedSessionBinding({ - channel: "matrix", - accountId: matrixSessionBindingAuth.accountId, - conversationId: "$thread", - parentConversationId: "!room:example.org", - targetSessionKey: "agent:matrix:thread", - }); - return binding; - }, - unbindAndVerify: unbindAndExpectClearedSessionBinding, - cleanup: async () => { - expectClearedSessionBinding({ - channel: "matrix", - accountId: matrixSessionBindingAuth.accountId, - conversationId: "$thread", - }); - }, - }, - telegram: { - preload: async () => { - await getContractApi("telegram"); }, + }), + telegram: createSessionBindingContractEntry({ + id: "telegram", + accountId: "default", + conversationId: "-100200300:topic:77", + targetSessionKey: "agent:telegram:child:thread-1", + targetKind: "subagent", + label: "telegram-topic", + placements: ["current", "child"], + preload: () => getContractApi("telegram"), beforeEach: prepareTelegramSessionBindingContract, - expectedCapabilities: { - adapterAvailable: true, - bindSupported: true, - unbindSupported: true, - placements: ["current", "child"], - }, - getCapabilities: async () => { + ensureManager: async () => { const { createTelegramThreadBindingManager } = await getContractApi("telegram"); createTelegramThreadBindingManager({ @@ -534,52 +429,9 @@ const sessionBindingContractEntries: Record< persist: false, enableSweeper: false, }); - return getSessionBindingService().getCapabilities({ - channel: "telegram", - accountId: "default", - }); }, - bindAndResolve: async () => { - const { createTelegramThreadBindingManager } = - await getContractApi("telegram"); - createTelegramThreadBindingManager({ - accountId: "default", - persist: false, - enableSweeper: false, - }); - const service = getSessionBindingService(); - const binding = await service.bind({ - targetSessionKey: "agent:telegram:child:thread-1", - targetKind: "subagent", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-100200300:topic:77", - }, - placement: "current", - metadata: { - agentId: "telegram", - label: "telegram-topic", - }, - }); - expectResolvedSessionBinding({ - channel: "telegram", - accountId: "default", - conversationId: "-100200300:topic:77", - targetSessionKey: "agent:telegram:child:thread-1", - }); - return binding; - }, - unbindAndVerify: unbindAndExpectClearedSessionBinding, - cleanup: async () => { - expectClearedSessionBinding({ - channel: "telegram", - accountId: "default", - conversationId: "-100200300:topic:77", - }); - }, - }, -}; + }), +} satisfies Record>; let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index ce207750a89c..ba11e35538f6 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -513,6 +513,36 @@ describe("update-cli", () => { legacyIssues: [], }; + const clawHubRiskWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const clawHubSuspiciousPayloadWarning = + "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + + "│ • Security scan: suspicious │\n" + + "│ • Finding: suspicious payload strings │\n" + + "╰───────────────────────────────────────────────────────────────────────╯"; + const clawHubSyncRiskError = + "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4)."; + + const createClawHubRiskRequest = ( + overrides: Partial = {}, + ): ClawHubRiskAcknowledgementRequest => ({ + packageName: "demo", + version: "1.2.3", + trust: { + scanStatus: "suspicious", + moderationState: null, + blockedFromDownload: false, + reasons: ["payload_strings"], + pending: false, + stale: false, + }, + acknowledgementKind: "confirm", + warning: clawHubRiskWarning, + ...overrides, + }); + const setTty = (value: boolean | undefined) => { Object.defineProperty(process.stdin, "isTTY", { value, @@ -952,7 +982,11 @@ describe("update-cli", () => { }); }; - const pluginSyncResult = (config: OpenClawConfig, changed = false) => ({ + const pluginSyncResult = ( + config: OpenClawConfig, + changed = false, + overrides: { warnings?: string[]; errors?: string[] } = {}, + ) => ({ changed, config, summary: { @@ -961,6 +995,7 @@ describe("update-cli", () => { switchedToNpm: [], warnings: [], errors: [], + ...overrides, }, }); @@ -1306,22 +1341,8 @@ describe("update-cli", () => { formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]); mockGatewayProbe("1.0.0", "conn-test"); pathExists.mockResolvedValue(false); - syncPluginsForUpdateChannel.mockResolvedValue({ - changed: false, - config: baseConfig, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [], - errors: [], - }, - }); - updateNpmInstalledPlugins.mockResolvedValue({ - changed: false, - config: baseConfig, - outcomes: [], - }); + syncPluginsForUpdateChannel.mockResolvedValue(pluginSyncResult(baseConfig)); + updateNpmInstalledPlugins.mockResolvedValue(npmPluginUpdateResult(baseConfig)); checkShellCompletionStatus.mockResolvedValue({ shell: "zsh", profileInstalled: false, @@ -2393,10 +2414,7 @@ describe("update-cli", () => { vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( "/tmp/openclaw-updated-entry.mjs", ); - const trustWarning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const trustWarning = clawHubRiskWarning; const coloredTrustWarning = `\u001b[33m${trustWarning}\u001b[39m`; updateNpmInstalledPlugins.mockImplementationOnce( async (params: { @@ -2432,24 +2450,13 @@ describe("update-cli", () => { }); it("includes failed ClawHub sync trust warnings in json post-core plugin output", async () => { - const trustWarning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "│ • Finding: suspicious payload strings │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; - syncPluginsForUpdateChannel.mockResolvedValueOnce({ - changed: false, - config: baseConfig, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], + const trustWarning = clawHubSuspiciousPayloadWarning; + syncPluginsForUpdateChannel.mockResolvedValueOnce( + pluginSyncResult(baseConfig, false, { warnings: [trustWarning], - errors: [ - "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", - ], - }, - }); + errors: [clawHubSyncRiskError], + }), + ); vi.mocked(defaultRuntime.writeJson).mockClear(); await updateCommand({ json: true, restart: false }); @@ -2457,33 +2464,18 @@ describe("update-cli", () => { const jsonOutput = lastWriteJsonCall() as UpdateRunResult | undefined; expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning"); expect(jsonOutput?.postUpdate?.plugins?.sync.warnings).toEqual([trustWarning]); - expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([ - "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", - ]); + expect(jsonOutput?.postUpdate?.plugins?.sync.errors).toEqual([clawHubSyncRiskError]); }); it("does not print duplicate failed ClawHub sync trust warnings in human post-core output", async () => { - const trustWarning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "│ • Finding: suspicious payload strings │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const trustWarning = clawHubSuspiciousPayloadWarning; syncPluginsForUpdateChannel.mockImplementationOnce( async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { params.logger?.warn?.(trustWarning); - return { - changed: false, - config: params.config, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [trustWarning], - errors: [ - "Failed to update demo: Update cancelled; rerun with --acknowledge-clawhub-risk to continue after reviewing the warning. (ClawHub clawhub:demo@1.2.4).", - ], - }, - }; + return pluginSyncResult(params.config, false, { + warnings: [trustWarning], + errors: [clawHubSyncRiskError], + }); }, ); @@ -2494,11 +2486,7 @@ describe("update-cli", () => { }); it("does not print duplicate ClawHub update trust warnings in human post-core output", async () => { - const trustWarning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "│ • Finding: suspicious payload strings │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const trustWarning = clawHubSuspiciousPayloadWarning; updateNpmInstalledPlugins.mockImplementationOnce( async (params: { config: OpenClawConfig; logger?: { warn?: (message: string) => void } }) => { params.logger?.warn?.(trustWarning); @@ -2547,17 +2535,7 @@ describe("update-cli", () => { installPath, }, }); - syncPluginsForUpdateChannel.mockResolvedValueOnce({ - changed: false, - config, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [], - errors: [], - }, - }); + syncPluginsForUpdateChannel.mockResolvedValueOnce(pluginSyncResult(config)); pathExists.mockImplementation(async (candidate: string) => candidate === installPath); vi.mocked(defaultRuntime.writeJson).mockClear(); @@ -2630,11 +2608,7 @@ describe("update-cli", () => { }); it("marks unacknowledged ClawHub risk skips as post-update warnings", async () => { - const trustWarning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "│ • Finding: suspicious payload strings │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const trustWarning = clawHubSuspiciousPayloadWarning; updateNpmInstalledPlugins.mockResolvedValueOnce({ changed: false, config: baseConfig, @@ -5508,23 +5482,18 @@ describe("update-cli", () => { config: postDoctorConfig, hash: "post-doctor-hash", }); - syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({ - changed: true, - config: { - ...config, - plugins: { - ...config.plugins, - load: { paths: ["/tmp/openclaw-updated-plugin"] }, + syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => + pluginSyncResult( + { + ...config, + plugins: { + ...config.plugins, + load: { paths: ["/tmp/openclaw-updated-plugin"] }, + }, }, - }, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [], - errors: [], - }, - })); + true, + ), + ); updateNpmInstalledPlugins.mockImplementation(async ({ config }) => npmPluginUpdateResult(config), ); @@ -5911,22 +5880,8 @@ describe("update-cli", () => { }, } as OpenClawConfig, }); - syncPluginsForUpdateChannel.mockResolvedValue({ - changed: false, - config: sourceConfig, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [], - errors: [], - }, - }); - updateNpmInstalledPlugins.mockResolvedValue({ - changed: false, - config: sourceConfig, - outcomes: [], - }); + syncPluginsForUpdateChannel.mockResolvedValue(pluginSyncResult(sourceConfig)); + updateNpmInstalledPlugins.mockResolvedValue(npmPluginUpdateResult(sourceConfig)); await updateCommand({ channel: "beta", yes: true }); @@ -5995,20 +5950,13 @@ describe("update-cli", () => { confirm.mockClear(); confirm.mockResolvedValueOnce(true); - await syncCall.onClawHubRisk({ - packageName: "demo\npkg", - version: "1.2.3\u001b[2K", - trust: { - scanStatus: "suspicious", - moderationState: null, - blockedFromDownload: false, - reasons: ["payload_strings"], - pending: false, - stale: false, - }, - acknowledgementKind: "confirm", - warning: "warning", - }); + await syncCall.onClawHubRisk( + createClawHubRiskRequest({ + packageName: "demo\npkg", + version: "1.2.3\u001b[2K", + warning: "warning", + }), + ); const message = getConfirmMessage(); expect(message).toContain("Update ClawHub package"); @@ -6018,10 +5966,7 @@ describe("update-cli", () => { }); it("prints ClawHub risk warnings before interactive post-update acknowledgement prompts", async () => { - const warning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const warning = clawHubRiskWarning; const syncCall = await setupInteractiveClawHubRisk(); confirm.mockImplementationOnce(async () => { @@ -6029,27 +5974,11 @@ describe("update-cli", () => { expect(logs.some((line) => line.includes(warning))).toBe(true); return true; }); - await syncCall.onClawHubRisk({ - packageName: "demo", - version: "1.2.3", - trust: { - scanStatus: "suspicious", - moderationState: null, - blockedFromDownload: false, - reasons: ["payload_strings"], - pending: false, - stale: false, - }, - acknowledgementKind: "confirm", - warning, - }); + await syncCall.onClawHubRisk(createClawHubRiskRequest({ warning })); }); it("does not duplicate ClawHub risk warnings already printed before prompts", async () => { - const warning = - "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + - "│ • Security scan: suspicious │\n" + - "╰───────────────────────────────────────────────────────────────────────╯"; + const warning = clawHubRiskWarning; const syncCall = await setupInteractiveClawHubRisk(); const logger = syncCall.logger; if ( @@ -6064,20 +5993,7 @@ describe("update-cli", () => { logger.warn(`\u001b[33m${warning}\u001b[39m`); confirm.mockResolvedValueOnce(true); - await syncCall.onClawHubRisk({ - packageName: "demo", - version: "1.2.3", - trust: { - scanStatus: "suspicious", - moderationState: null, - blockedFromDownload: false, - reasons: ["payload_strings"], - pending: false, - stale: false, - }, - acknowledgementKind: "confirm", - warning, - }); + await syncCall.onClawHubRisk(createClawHubRiskRequest({ warning })); const output = getLogOutput(); const occurrences = output.split(warning).length - 1; @@ -6777,22 +6693,14 @@ describe("update-cli", () => { update: { channel: "beta" }, plugins: { entries: { post: { enabled: true } } }, } as OpenClawConfig; - const preDoctorSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: preDoctorConfig, - resolved: preDoctorConfig, - runtimeConfig: preDoctorConfig, - config: preDoctorConfig, + const preDoctorSnapshot = configSnapshot(preDoctorConfig, { + parsed: baseSnapshot.parsed, hash: "pre-doctor", - }; - const postDoctorSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: postDoctorConfig, - resolved: postDoctorConfig, - runtimeConfig: postDoctorConfig, - config: postDoctorConfig, + }); + const postDoctorSnapshot = configSnapshot(postDoctorConfig, { + parsed: baseSnapshot.parsed, hash: "post-doctor", - }; + }); const postDoctorRecords = { "post-plugin": { source: "npm", @@ -6805,17 +6713,8 @@ describe("update-cli", () => { .mockResolvedValueOnce(postDoctorSnapshot); loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords); syncPluginsForUpdateChannel.mockImplementationOnce( - async (params: { config?: OpenClawConfig }) => ({ - changed: true, - config: params.config ?? baseConfig, - summary: { - switchedToBundled: [], - switchedToClawHub: [], - switchedToNpm: [], - warnings: [], - errors: [], - }, - }), + async (params: { config?: OpenClawConfig }) => + pluginSyncResult(params.config ?? baseConfig, true), ); await updateFinalizeCommand({ json: true, timeout: "9", restart: false }); @@ -6874,14 +6773,10 @@ describe("update-cli", () => { const postDoctorConfig = { meta: { lastTouchedVersion: "2026.6.18" }, } as OpenClawConfig; - const postDoctorSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: postDoctorConfig, - resolved: postDoctorConfig, - runtimeConfig: postDoctorConfig, - config: postDoctorConfig, + const postDoctorSnapshot = configSnapshot(postDoctorConfig, { + parsed: baseSnapshot.parsed, hash: "post-doctor", - }; + }); await fs.mkdir(tempDir, { recursive: true }); await fs.writeFile( sourceConfigPath, @@ -6913,22 +6808,14 @@ describe("update-cli", () => { it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => { const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig; const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig; - const preDoctorSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: preDoctorConfig, - resolved: preDoctorConfig, - runtimeConfig: preDoctorConfig, - config: preDoctorConfig, + const preDoctorSnapshot = configSnapshot(preDoctorConfig, { + parsed: baseSnapshot.parsed, hash: "pre-doctor", - }; - const postDoctorSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: postDoctorConfig, - resolved: postDoctorConfig, - runtimeConfig: postDoctorConfig, - config: postDoctorConfig, + }); + const postDoctorSnapshot = configSnapshot(postDoctorConfig, { + parsed: baseSnapshot.parsed, hash: "post-doctor", - }; + }); vi.mocked(readConfigFileSnapshot) .mockResolvedValueOnce(preDoctorSnapshot) .mockResolvedValueOnce(preDoctorSnapshot) @@ -6947,14 +6834,10 @@ describe("update-cli", () => { it("updateFinalizeCommand converges on the effective channel from env without persisting update.channel", async () => { const noChannelConfig = {} as OpenClawConfig; - const noChannelSnapshot: ConfigFileSnapshot = { - ...baseSnapshot, - sourceConfig: noChannelConfig, - resolved: noChannelConfig, - runtimeConfig: noChannelConfig, - config: noChannelConfig, + const noChannelSnapshot = configSnapshot(noChannelConfig, { + parsed: baseSnapshot.parsed, hash: "no-channel", - }; + }); vi.mocked(readConfigFileSnapshot).mockResolvedValue(noChannelSnapshot); const priorEffective = process.env.OPENCLAW_UPDATE_EFFECTIVE_CHANNEL; // Simulate a no-config git/source update whose effective channel is dev. diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index 59392c60db33..77bbe7f55226 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -6,10 +6,7 @@ import { type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; - -function hasOwnKey(target: Record, key: string): boolean { - return Object.hasOwn(target, key); -} +import { hasOwnKey, visitChannelEntries } from "./legacy-config-record-shared.js"; function cleanupEmptyRecord(parent: Record, key: string): void { const value = getRecord(parent[key]); @@ -257,31 +254,13 @@ function hasLegacyThreadBindingSpawnSplit(value: unknown): boolean { ); } -function hasLegacyThreadBindingTtlInAccounts(value: unknown): boolean { - const accounts = getRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((entry) => - hasLegacyThreadBindingTtl(getRecord(entry)?.threadBindings), - ); -} - -function hasLegacyThreadBindingSpawnSplitInAccounts(value: unknown): boolean { - const accounts = getRecord(value); - if (!accounts) { - return false; - } - return Object.values(accounts).some((entry) => - hasLegacyThreadBindingSpawnSplit(getRecord(entry)?.threadBindings), - ); -} - -function migrateThreadBindingsTtlHoursForPath(params: { +type ThreadBindingMigrationParams = { owner: Record; pathPrefix: string; changes: string[]; -}): boolean { +}; + +function migrateThreadBindingsTtlHoursForPath(params: ThreadBindingMigrationParams): boolean { const threadBindings = getRecord(params.owner.threadBindings); if (!threadBindings || !hasOwnKey(threadBindings, "ttlHours")) { return false; @@ -322,11 +301,7 @@ function resolveMigratedSpawnSessions( return subagentBool && acpBool; } -function migrateThreadBindingsSpawnSessionsForPath(params: { - owner: Record; - pathPrefix: string; - changes: string[]; -}): boolean { +function migrateThreadBindingsSpawnSessionsForPath(params: ThreadBindingMigrationParams): boolean { const threadBindings = getRecord(params.owner.threadBindings); if (!threadBindings || !hasLegacyThreadBindingSpawnSplit(threadBindings)) { return false; @@ -363,24 +338,15 @@ function migrateThreadBindingsSpawnSessionsForPath(params: { return true; } -function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { - const channels = getRecord(value); - if (!channels) { - return false; - } - return Object.values(channels).some((entry) => { - const channel = getRecord(entry); - if (!channel) { - return false; - } - return ( - hasLegacyThreadBindingTtl(channel.threadBindings) || - hasLegacyThreadBindingTtlInAccounts(channel.accounts) - ); - }); +function migrateThreadBindingsForPath(params: ThreadBindingMigrationParams): void { + migrateThreadBindingsTtlHoursForPath(params); + migrateThreadBindingsSpawnSessionsForPath(params); } -function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean { +function hasLegacyThreadBindingInAnyChannel( + value: unknown, + matcher: (value: unknown) => boolean, +): boolean { const channels = getRecord(value); if (!channels) { return false; @@ -391,8 +357,10 @@ function hasLegacyThreadBindingSpawnSplitInAnyChannel(value: unknown): boolean { return false; } return ( - hasLegacyThreadBindingSpawnSplit(channel.threadBindings) || - hasLegacyThreadBindingSpawnSplitInAccounts(channel.accounts) + matcher(channel.threadBindings) || + Object.values(getRecord(channel.accounts) ?? {}).some((account) => + matcher(getRecord(account)?.threadBindings), + ) ); }); } @@ -408,7 +376,7 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ path: ["channels"], message: 'channels..threadBindings.ttlHours was renamed to channels..threadBindings.idleHours. Run "openclaw doctor --fix".', - match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value), + match: (value) => hasLegacyThreadBindingInAnyChannel(value, hasLegacyThreadBindingTtl), }, { path: ["session", "threadBindings"], @@ -420,7 +388,7 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ path: ["channels"], message: 'channels..threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels..threadBindings.spawnSessions. Run "openclaw doctor --fix".', - match: (value) => hasLegacyThreadBindingSpawnSplitInAnyChannel(value), + match: (value) => hasLegacyThreadBindingInAnyChannel(value, hasLegacyThreadBindingSpawnSplit), }, ]; @@ -486,9 +454,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ id: "channels.webchat-remove", describe: "Remove retired WebChat channel config", legacyRules: WEBCHAT_CHANNEL_RULES, - apply: (raw, changes) => { - migrateRetiredWebchatChannelConfig(raw, changes); - }, + apply: migrateRetiredWebchatChannelConfig, }), defineLegacyConfigMigration({ id: "legacy-group-routing->channel-groups", @@ -505,9 +471,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ id: "feishu.accounts.botName->name", describe: "Move legacy Feishu account botName config to account name", legacyRules: FEISHU_ACCOUNT_RULES, - apply: (raw, changes) => { - migrateFeishuAccountBotName(raw, changes); - }, + apply: migrateFeishuAccountBotName, }), defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", @@ -517,17 +481,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ apply: (raw, changes) => { const session = getRecord(raw.session); if (session) { - migrateThreadBindingsTtlHoursForPath({ - owner: session, - pathPrefix: "session", - changes, - }); - migrateThreadBindingsSpawnSessionsForPath({ - owner: session, - pathPrefix: "session", - changes, - }); - raw.session = session; + migrateThreadBindingsForPath({ owner: session, pathPrefix: "session", changes }); } const channels = getRecord(raw.channels); @@ -535,46 +489,11 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ return; } - for (const [channelId, channelRaw] of Object.entries(channels)) { - const channel = getRecord(channelRaw); - if (!channel) { - continue; - } - migrateThreadBindingsTtlHoursForPath({ - owner: channel, - pathPrefix: `channels.${channelId}`, - changes, + for (const channelId of Object.keys(channels)) { + visitChannelEntries(raw, channelId, (owner, pathPrefix) => { + migrateThreadBindingsForPath({ owner, pathPrefix, changes }); }); - migrateThreadBindingsSpawnSessionsForPath({ - owner: channel, - pathPrefix: `channels.${channelId}`, - changes, - }); - - const accounts = getRecord(channel.accounts); - if (accounts) { - for (const [accountId, accountRaw] of Object.entries(accounts)) { - const account = getRecord(accountRaw); - if (!account) { - continue; - } - migrateThreadBindingsTtlHoursForPath({ - owner: account, - pathPrefix: `channels.${channelId}.accounts.${accountId}`, - changes, - }); - migrateThreadBindingsSpawnSessionsForPath({ - owner: account, - pathPrefix: `channels.${channelId}.accounts.${accountId}`, - changes, - }); - accounts[accountId] = account; - } - channel.accounts = accounts; - } - channels[channelId] = channel; } - raw.channels = channels; }, }), ]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts index 700b709629cc..fddd272860db 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts @@ -31,10 +31,6 @@ function hasLegacyTtsProviderKeys(value: unknown): boolean { return Boolean(providers && Object.hasOwn(providers, "edge")); } -function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean { - return hasLegacyTtsInPluginLocations(value, hasLegacyTtsProviderKeys); -} - function hasLegacyTtsEnabled(value: unknown): boolean { return typeof getRecord(value)?.enabled === "boolean"; } @@ -158,30 +154,6 @@ function hasLegacyTtsInPluginLocations(value: unknown, matcher: LegacyTtsMatcher }); } -function hasLegacyTtsSpeakerSelectionInAgentLocations(value: unknown): boolean { - return hasLegacyTtsInAgentLocations(value, hasLegacyTtsSpeakerSelection); -} - -function hasLegacyTtsSpeakerSelectionInChannelLocations(value: unknown): boolean { - return hasLegacyTtsInChannelLocations(value, hasLegacyTtsSpeakerSelection); -} - -function hasLegacyTtsSpeakerSelectionInPluginLocations(value: unknown): boolean { - return hasLegacyTtsInPluginLocations(value, hasLegacyTtsSpeakerSelection); -} - -function hasLegacyTtsEnabledInAgentLocations(value: unknown): boolean { - return hasLegacyTtsInAgentLocations(value, hasLegacyTtsEnabled); -} - -function hasLegacyTtsEnabledInChannelLocations(value: unknown): boolean { - return hasLegacyTtsInChannelLocations(value, hasLegacyTtsEnabled); -} - -function hasLegacyTtsEnabledInPluginLocations(value: unknown): boolean { - return hasLegacyTtsInPluginLocations(value, hasLegacyTtsEnabled); -} - function getOrCreateTtsProviders(tts: Record): Record { const providers = getRecord(tts.providers) ?? {}; tts.providers = providers; @@ -192,35 +164,19 @@ function mergeLegacyTtsProviderConfig( tts: Record, legacyKey: string, providerId: string, + source: "tts" | "providers" = "tts", ): boolean { - const legacyValue = getRecord(tts[legacyKey]); - if (!legacyValue) { + const legacyOwner = source === "providers" ? getRecord(tts.providers) : tts; + const legacyValue = getRecord(legacyOwner?.[legacyKey]); + if (!legacyOwner || !legacyValue) { return false; } - const providers = getOrCreateTtsProviders(tts); + const providers = source === "providers" ? legacyOwner : getOrCreateTtsProviders(tts); const existing = getRecord(providers[providerId]) ?? {}; const merged = structuredClone(existing); mergeMissing(merged, legacyValue); providers[providerId] = merged; - delete tts[legacyKey]; - return true; -} - -function mergeLegacyTtsProviderAliasConfig( - tts: Record, - aliasKey: string, - providerId: string, -): boolean { - const providers = getRecord(tts.providers); - const aliasValue = getRecord(providers?.[aliasKey]); - if (!providers || !aliasValue) { - return false; - } - const existing = getRecord(providers[providerId]) ?? {}; - const merged = structuredClone(existing); - mergeMissing(merged, aliasValue); - providers[providerId] = merged; - delete providers[aliasKey]; + delete legacyOwner[legacyKey]; return true; } @@ -236,26 +192,19 @@ function migrateLegacyTtsConfig( tts.provider = "microsoft"; changes.push(`Moved ${pathLabel}.provider "edge" → "microsoft".`); } - const movedOpenAI = mergeLegacyTtsProviderConfig(tts, "openai", "openai"); - const movedElevenLabs = mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs"); - const movedMicrosoft = mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft"); - const movedProviderEdge = mergeLegacyTtsProviderAliasConfig(tts, "edge", "microsoft"); - const movedEdge = mergeLegacyTtsProviderConfig(tts, "edge", "microsoft"); - - if (movedOpenAI) { - changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`); - } - if (movedElevenLabs) { - changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`); - } - if (movedMicrosoft) { - changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`); - } - if (movedProviderEdge) { - changes.push(`Moved ${pathLabel}.providers.edge → ${pathLabel}.providers.microsoft.`); - } - if (movedEdge) { - changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`); + for (const [legacyKey, providerId, source] of [ + ["openai", "openai", "tts"], + ["elevenlabs", "elevenlabs", "tts"], + ["microsoft", "microsoft", "tts"], + ["edge", "microsoft", "providers"], + ["edge", "microsoft", "tts"], + ] as const) { + if (!mergeLegacyTtsProviderConfig(tts, legacyKey, providerId, source)) { + continue; + } + const sourcePath = + source === "providers" ? `${pathLabel}.providers.${legacyKey}` : `${pathLabel}.${legacyKey}`; + changes.push(`Moved ${sourcePath} → ${pathLabel}.providers.${providerId}.`); } } @@ -282,36 +231,23 @@ function migrateLegacySpeakerSelectionConfig( pathLabel: string, changes: string[], ): void { - if (Object.hasOwn(providerConfig, "voice")) { - if (providerConfig.speakerVoice === undefined) { - providerConfig.speakerVoice = providerConfig.voice; - changes.push(`Moved ${pathLabel}.voice → ${pathLabel}.speakerVoice.`); - } else { - changes.push(`Removed ${pathLabel}.voice because ${pathLabel}.speakerVoice is already set.`); + for (const [legacyKey, canonicalKey] of [ + ["voice", "speakerVoice"], + ["voiceName", "speakerVoice"], + ["voiceId", "speakerVoiceId"], + ] as const) { + if (!Object.hasOwn(providerConfig, legacyKey)) { + continue; } - delete providerConfig.voice; - } - if (Object.hasOwn(providerConfig, "voiceName")) { - if (providerConfig.speakerVoice === undefined) { - providerConfig.speakerVoice = providerConfig.voiceName; - changes.push(`Moved ${pathLabel}.voiceName → ${pathLabel}.speakerVoice.`); + if (providerConfig[canonicalKey] === undefined) { + providerConfig[canonicalKey] = providerConfig[legacyKey]; + changes.push(`Moved ${pathLabel}.${legacyKey} → ${pathLabel}.${canonicalKey}.`); } else { changes.push( - `Removed ${pathLabel}.voiceName because ${pathLabel}.speakerVoice is already set.`, + `Removed ${pathLabel}.${legacyKey} because ${pathLabel}.${canonicalKey} is already set.`, ); } - delete providerConfig.voiceName; - } - if (Object.hasOwn(providerConfig, "voiceId")) { - if (providerConfig.speakerVoiceId === undefined) { - providerConfig.speakerVoiceId = providerConfig.voiceId; - changes.push(`Moved ${pathLabel}.voiceId → ${pathLabel}.speakerVoiceId.`); - } else { - changes.push( - `Removed ${pathLabel}.voiceId because ${pathLabel}.speakerVoiceId is already set.`, - ); - } - delete providerConfig.voiceId; + delete providerConfig[legacyKey]; } } @@ -443,7 +379,7 @@ const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [ path: ["plugins", "entries"], message: 'plugins.entries.voice-call.config.tts legacy provider aliases/keys are legacy; use provider: "microsoft" and plugins.entries.voice-call.config.tts.providers.. Run "openclaw doctor --fix".', - match: (value) => hasLegacyPluginEntryTtsProviderKeys(value), + match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsProviderKeys), }, ]; @@ -457,19 +393,19 @@ const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [ path: ["agents"], message: 'agents.list[].tts.enabled is legacy; use agents.list[].tts.auto. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsEnabledInAgentLocations(value), + match: (value) => hasLegacyTtsInAgentLocations(value, hasLegacyTtsEnabled), }, { path: ["channels"], message: 'supported channel TTS enabled fields are legacy; use the same TTS block auto field. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsEnabledInChannelLocations(value), + match: (value) => hasLegacyTtsInChannelLocations(value, hasLegacyTtsEnabled), }, { path: ["plugins", "entries"], message: 'plugins.entries.voice-call.config.tts.enabled is legacy; use plugins.entries.voice-call.config.tts.auto. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsEnabledInPluginLocations(value), + match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsEnabled), }, ]; @@ -484,19 +420,19 @@ const LEGACY_TTS_SPEAKER_SELECTION_RULES: LegacyConfigRule[] = [ path: ["agents"], message: 'agents.list[].tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsSpeakerSelectionInAgentLocations(value), + match: (value) => hasLegacyTtsInAgentLocations(value, hasLegacyTtsSpeakerSelection), }, { path: ["channels"], message: 'supported channel TTS speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsSpeakerSelectionInChannelLocations(value), + match: (value) => hasLegacyTtsInChannelLocations(value, hasLegacyTtsSpeakerSelection), }, { path: ["plugins", "entries"], message: 'plugins.entries.voice-call.config.tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".', - match: (value) => hasLegacyTtsSpeakerSelectionInPluginLocations(value), + match: (value) => hasLegacyTtsInPluginLocations(value, hasLegacyTtsSpeakerSelection), }, ]; diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 59b04c4a661c..6dc966b40860 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -1,7 +1,6 @@ // Filesystem session history readers. // Parses transcript JSONL files for messages, previews, counts, and usage metadata. import fs from "node:fs"; -import { StringDecoder } from "node:string_decoder"; import { expectDefined } from "@openclaw/normalization-core"; import { resolveIntegerOption, @@ -16,6 +15,7 @@ import { } from "../agents/usage.js"; import { materializeSessionArchiveForRead } from "../config/sessions/archive-compression.js"; import type { TranscriptEvent } from "../config/sessions/session-accessor.js"; +import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js"; import { selectSessionTranscriptActiveEntries } from "../config/sessions/transcript-tree.js"; import { readFileWindowFully } from "../infra/file-read.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; @@ -41,52 +41,6 @@ import { } from "./session-transcript-json.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; -const transcriptMessageCountCache = new Map< - string, - { - mtimeMs: number; - size: number; - count: number; - } ->(); -const MAX_TRANSCRIPT_MESSAGE_COUNT_CACHE_ENTRIES = 5000; -const TRANSCRIPT_ASYNC_READ_CHUNK_BYTES = 64 * 1024; - -function getCachedTranscriptMessageCount(filePath: string, stat: fs.Stats): number | null { - const cached = transcriptMessageCountCache.get(filePath); - if (!cached) { - return null; - } - if (cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) { - transcriptMessageCountCache.delete(filePath); - return null; - } - transcriptMessageCountCache.delete(filePath); - transcriptMessageCountCache.set(filePath, cached); - return cached.count; -} - -function setCachedTranscriptMessageCount(filePath: string, stat: fs.Stats, count: number): void { - transcriptMessageCountCache.set(filePath, { - mtimeMs: stat.mtimeMs, - size: stat.size, - count, - }); - while (transcriptMessageCountCache.size > MAX_TRANSCRIPT_MESSAGE_COUNT_CACHE_ENTRIES) { - const oldestKey = transcriptMessageCountCache.keys().next().value; - if (typeof oldestKey !== "string" || !oldestKey) { - break; - } - transcriptMessageCountCache.delete(oldestKey); - } -} - -async function yieldTranscriptScan(): Promise { - await new Promise((resolve) => { - setImmediate(resolve); - }); -} - /** Attach OpenClaw metadata to a transcript message without dropping existing metadata. */ export function attachOpenClawTranscriptMeta( message: unknown, @@ -344,37 +298,6 @@ function parseRecentTranscriptTailSnapshot( }; } -async function visitTranscriptLinesAsync( - filePath: string, - visit: (line: string) => void, -): Promise { - const handle = await fs.promises.open(filePath, "r"); - try { - const decoder = new StringDecoder("utf8"); - const buffer = Buffer.allocUnsafe(TRANSCRIPT_ASYNC_READ_CHUNK_BYTES); - let carry = ""; - while (true) { - const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); - if (bytesRead <= 0) { - break; - } - const text = carry + decoder.write(buffer.subarray(0, bytesRead)); - const lines = text.split(/\r?\n/); - carry = lines.pop() ?? ""; - for (const line of lines) { - visit(line); - } - await yieldTranscriptScan(); - } - const tail = carry + decoder.end(); - if (tail) { - visit(tail); - } - } finally { - await handle.close(); - } -} - export async function readSessionMessagesAsync( sessionId: string, storePath: string | undefined, @@ -555,7 +478,8 @@ export async function readRecentSessionMessagesWithStatsAsync( findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId) !== filePath ? "reset-archive" : "active"; - const totalMessages = await readSessionMessageCountFromPathAsync(filePath); + // The canonical index already caches and deduplicates scans by path, mtime, and size. + const totalMessages = (await readSessionTranscriptIndex(filePath))?.entries.length ?? 0; const snapshot = await readRecentSessionSnapshotFromPathAsync( filePath, normalizeRecentSessionReadOptions(opts), @@ -747,25 +671,6 @@ export async function resolveSessionHistoryTranscriptPathAsync( : findExistingTranscriptPath(sessionId, storePath, sessionFile, opts?.agentId); } -async function readSessionMessageCountFromPathAsync(filePath: string): Promise { - let stat: fs.Stats | null = null; - try { - stat = await fs.promises.stat(filePath); - const cached = getCachedTranscriptMessageCount(filePath, stat); - if (typeof cached === "number") { - return cached; - } - } catch { - // Count from the transcript index below when stat metadata is unavailable. - } - const index = await readSessionTranscriptIndex(filePath); - const count = index?.entries.length ?? 0; - if (stat) { - setCachedTranscriptMessageCount(filePath, stat, count); - } - return count; -} - export type SessionTranscriptUsageSnapshot = { modelProvider?: string; model?: string; @@ -1085,11 +990,9 @@ export async function readLatestSessionUsageFromTranscriptAsync( return null; } const lines: string[] = []; - await visitTranscriptLinesAsync(filePath, (line) => { - if (line.trim()) { - lines.push(line); - } - }); + for await (const line of streamSessionTranscriptLines(filePath)) { + lines.push(line); + } return extractAggregateUsageFromTranscriptLines(lines); } catch { return null; diff --git a/src/gateway/watch-node-http.test.ts b/src/gateway/watch-node-http.test.ts index 13dae3095732..d9b0968a138f 100644 --- a/src/gateway/watch-node-http.test.ts +++ b/src/gateway/watch-node-http.test.ts @@ -176,10 +176,48 @@ async function startRuntime( }; } +async function createWatchNodeFixture( + prefix: string, + options?: Parameters[1], +) { + const baseDir = await tempDirs.make(prefix); + const identity = loadOrCreateDeviceIdentity({ + path: path.join(baseDir, "watch-identity.sqlite"), + }); + const issued = await issueDeviceBootstrapToken({ + baseDir, + profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + return { baseDir, identity, issued, ...(await startRuntime(baseDir, options)) }; +} + async function readJson(response: Response): Promise> { return (await response.json()) as Record; } +async function connectWatchNode(params: { + baseUrl: string; + identity: ReturnType; + bootstrapToken?: string; + deviceToken?: string; + permissions?: ConnectParams["permissions"]; +}): Promise { + const challenge = await readJson(await fetch(`${params.baseUrl}/challenge`)); + return await fetch(`${params.baseUrl}/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + makeConnectParams({ + identity: params.identity, + nonce: String(challenge.nonce), + bootstrapToken: params.bootstrapToken, + deviceToken: params.deviceToken, + permissions: params.permissions, + }), + ), + }); +} + function startPartialJsonRequest(params: { url: string; authorization: string }): { request: ClientRequest; response: Promise<{ statusCode: number; body: string }>; @@ -220,15 +258,9 @@ async function waitForLastConnectedMetadata(baseDir: string, nodeId: string): Pr describe("watch node HTTP transport", () => { it("rejects capabilities and identities outside the bounded watch surface", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-surface-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { baseUrl, runtime } = await startRuntime(baseDir); + const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture( + "openclaw-watch-node-surface-", + ); const variants: Array<(nonce: string) => ConnectParams> = [ (nonce) => makeConnectParams({ @@ -279,27 +311,14 @@ describe("watch node HTTP transport", () => { }); it("accepts a supported notification permission set to false", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-permissions-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { baseUrl, runtime } = await startRuntime(baseDir); - const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const response = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(challenge.nonce), - bootstrapToken: issued.token, - permissions: { notifications: false }, - }), - ), + const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture( + "openclaw-watch-node-permissions-", + ); + const response = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, + permissions: { notifications: false }, }); expect(response.status).toBe(200); @@ -308,17 +327,12 @@ describe("watch node HTTP transport", () => { }); it("does not let attacker challenges evict another client nonce", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-challenge-eviction-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { baseUrl, runtime } = await startRuntime(baseDir, { - config: { gateway: { trustedProxies: ["127.0.0.1"] } }, - }); + const { identity, issued, baseUrl, runtime } = await createWatchNodeFixture( + "openclaw-watch-node-challenge-eviction-", + { + config: { gateway: { trustedProxies: ["127.0.0.1"] } }, + }, + ); const legitimateHeaders = { "x-forwarded-for": "203.0.113.10" }; const legitimate = await readJson( await fetch(`${baseUrl}/challenge`, { headers: legitimateHeaders }), @@ -347,28 +361,13 @@ describe("watch node HTTP transport", () => { }); it("requires an authenticated disconnect and emits one lifecycle teardown", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-disconnect-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } = - await startRuntime(baseDir); + const { identity, issued, nodeRegistry, connectedNodes, disconnectedNodes, runtime, baseUrl } = + await createWatchNodeFixture("openclaw-watch-node-disconnect-"); - const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const connectResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(challenge.nonce), - bootstrapToken: issued.token, - }), - ), + const connectResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, }); expect(connectResponse.status).toBe(200); const connected = await readJson(connectResponse); @@ -407,26 +406,12 @@ describe("watch node HTTP transport", () => { }); it("rejects an HTTP node session after an external reapproval changes its generation", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-reapproval-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { nodeRegistry, disconnectedNodes, runtime, baseUrl } = await startRuntime(baseDir); - const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const connectResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(challenge.nonce), - bootstrapToken: issued.token, - }), - ), + const { baseDir, identity, issued, nodeRegistry, disconnectedNodes, runtime, baseUrl } = + await createWatchNodeFixture("openclaw-watch-node-reapproval-"); + const connectResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, }); const connected = await readJson(connectResponse); const paired = await getPairedDevice(identity.deviceId, baseDir); @@ -456,26 +441,12 @@ describe("watch node HTTP transport", () => { }); it("rejects an invoke result when pairing changes during body upload", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-result-generation-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { nodeRegistry, disconnectedNodes, runtime, baseUrl } = await startRuntime(baseDir); - const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const connectResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(challenge.nonce), - bootstrapToken: issued.token, - }), - ), + const { baseDir, identity, issued, nodeRegistry, disconnectedNodes, runtime, baseUrl } = + await createWatchNodeFixture("openclaw-watch-node-result-generation-"); + const connectResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, }); const connected = await readJson(connectResponse); const invoke = nodeRegistry.invoke({ @@ -529,15 +500,9 @@ describe("watch node HTTP transport", () => { }); it("rejects empty shadow credentials without consuming the challenge", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-auth-fields-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ - baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { baseUrl, runtime } = await startRuntime(baseDir); + const { baseDir, identity, issued, baseUrl, runtime } = await createWatchNodeFixture( + "openclaw-watch-node-auth-fields-", + ); const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); const connect = makeConnectParams({ @@ -624,17 +589,10 @@ describe("watch node HTTP transport", () => { const completedRuntime = await startRuntime(completedBaseDir, { rateLimiter: completedLimiter, }); - const challenge = await readJson(await fetch(`${completedRuntime.baseUrl}/challenge`)); - const connectResponse = await fetch(`${completedRuntime.baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity: completedIdentity, - nonce: String(challenge.nonce), - bootstrapToken: completedBootstrap.token, - }), - ), + const connectResponse = await connectWatchNode({ + baseUrl: completedRuntime.baseUrl, + identity: completedIdentity, + bootstrapToken: completedBootstrap.token, }); expect(connectResponse.status).toBe(200); await readJson(connectResponse); @@ -649,28 +607,22 @@ describe("watch node HTTP transport", () => { }); it("bootstraps, registers, polls an invoke, and accepts its result", async () => { - const baseDir = await tempDirs.make("openclaw-watch-node-http-"); - const identity = loadOrCreateDeviceIdentity({ - path: path.join(baseDir, "watch-identity.sqlite"), - }); - const issued = await issueDeviceBootstrapToken({ + const { baseDir, - profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, - }); - const { nodeRegistry, broadcasts, connectedNodes, disconnectedNodes, runtime, baseUrl } = - await startRuntime(baseDir); + identity, + issued, + nodeRegistry, + broadcasts, + connectedNodes, + disconnectedNodes, + runtime, + baseUrl, + } = await createWatchNodeFixture("openclaw-watch-node-http-"); - const challenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const connectResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(challenge.nonce), - bootstrapToken: issued.token, - }), - ), + const connectResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: issued.token, }); expect(connectResponse.status).toBe(200); const connected = await readJson(connectResponse); @@ -685,17 +637,10 @@ describe("watch node HTTP transport", () => { expect(broadcasts.map((entry) => entry.event)).toContain("node.pair.resolved"); expect(connectedNodes).toEqual([identity.deviceId]); - const reconnectChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const reconnectResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(reconnectChallenge.nonce), - deviceToken: String(connected.deviceToken), - }), - ), + const reconnectResponse = await connectWatchNode({ + baseUrl, + identity, + deviceToken: String(connected.deviceToken), }); expect(reconnectResponse.status).toBe(200); const reconnected = await readJson(reconnectResponse); @@ -778,17 +723,10 @@ describe("watch node HTTP transport", () => { baseDir, profile: NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, }); - const replacementChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const replacementResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(replacementChallenge.nonce), - bootstrapToken: replacementBootstrap.token, - }), - ), + const replacementResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: replacementBootstrap.token, }); expect(replacementResponse.status).toBe(200); const replacement = await readJson(replacementResponse); @@ -796,17 +734,10 @@ describe("watch node HTTP transport", () => { expect(replacement.deviceToken).not.toBe(connected.deviceToken); expect(connectedNodes).toEqual([identity.deviceId, identity.deviceId, identity.deviceId]); - const replayChallenge = await readJson(await fetch(`${baseUrl}/challenge`)); - const replayResponse = await fetch(`${baseUrl}/connect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify( - makeConnectParams({ - identity, - nonce: String(replayChallenge.nonce), - bootstrapToken: replacementBootstrap.token, - }), - ), + const replayResponse = await connectWatchNode({ + baseUrl, + identity, + bootstrapToken: replacementBootstrap.token, }); expect(replayResponse.status).toBe(401);