mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
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
This commit is contained in:
committed by
GitHub
parent
8ffc567075
commit
430c293ee0
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }) {
|
||||
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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<FeishuConfig["groups"]> = 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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<NonNullable<OpenClawConfig["channels"]>["telegram"]>;
|
||||
reaction?: Record<string, unknown>;
|
||||
sentByBot?: boolean;
|
||||
expectedEnqueueCalls: number;
|
||||
expectedEvent?: string;
|
||||
};
|
||||
|
||||
async function withTelegramSpooledReplayUpdate<T>(
|
||||
update: object,
|
||||
fn: () => Promise<T>,
|
||||
@@ -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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<WhatsAppSetupInput>({
|
||||
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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | number> | ReadonlyArray<string | number>;
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 () => {
|
||||
|
||||
@@ -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<SessionEntry>;
|
||||
update: Omit<Parameters<typeof persistSessionUsageUpdate>[0], "storePath" | "sessionKey">;
|
||||
expected: Partial<SessionEntry>;
|
||||
}>)("$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";
|
||||
|
||||
@@ -263,22 +263,84 @@ async function prepareTelegramSessionBindingContract() {
|
||||
await api.resetTelegramThreadBindingsForTests();
|
||||
}
|
||||
|
||||
const sessionBindingContractEntries: Record<
|
||||
SessionBindingContractChannelId,
|
||||
Omit<SessionBindingContractEntry, "id">
|
||||
> = {
|
||||
discord: {
|
||||
type SessionBindingContractFixture = {
|
||||
id: SessionBindingContractChannelId;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
targetSessionKey: string;
|
||||
targetKind: SessionBindingRecord["targetKind"];
|
||||
label: string;
|
||||
placements: SessionBindingCapabilities["placements"];
|
||||
preload: () => Promise<unknown>;
|
||||
beforeEach: () => Promise<void>;
|
||||
ensureManager: () => Promise<void>;
|
||||
stopManager?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function createSessionBindingContractEntry(
|
||||
fixture: SessionBindingContractFixture,
|
||||
): Omit<SessionBindingContractEntry, "id"> {
|
||||
const conversation = {
|
||||
channel: fixture.id,
|
||||
accountId: fixture.accountId,
|
||||
conversationId: fixture.conversationId,
|
||||
...(fixture.parentConversationId ? { parentConversationId: fixture.parentConversationId } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
preload: async () => {
|
||||
await getContractApi<DiscordContractApi>("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<DiscordContractApi>("discord"),
|
||||
beforeEach: prepareDiscordSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
ensureManager: async () => {
|
||||
const { createThreadBindingManager } = await getContractApi<DiscordContractApi>("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<DiscordContractApi>("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<FeishuContractApi>("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<FeishuContractApi>("feishu"),
|
||||
beforeEach: prepareFeishuSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
ensureManager: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("feishu");
|
||||
createFeishuThreadBindingManager({
|
||||
accountId: "default",
|
||||
cfg: baseSessionBindingCfg,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
const { createFeishuThreadBindingManager } =
|
||||
await getContractApi<FeishuContractApi>("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<IMessageContractApi>("imessage");
|
||||
},
|
||||
}),
|
||||
imessage: createSessionBindingContractEntry({
|
||||
id: "imessage",
|
||||
accountId: "default",
|
||||
conversationId: "+15555550124",
|
||||
targetSessionKey: "agent:imessage:current",
|
||||
targetKind: "session",
|
||||
label: "imessage-main",
|
||||
placements: ["current"],
|
||||
preload: () => getContractApi<IMessageContractApi>("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<MatrixContractApi>("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<MatrixContractApi>("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<TelegramContractApi>("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<TelegramContractApi>("telegram"),
|
||||
beforeEach: prepareTelegramSessionBindingContract,
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: async () => {
|
||||
ensureManager: async () => {
|
||||
const { createTelegramThreadBindingManager } =
|
||||
await getContractApi<TelegramContractApi>("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<TelegramContractApi>("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<SessionBindingContractChannelId, Omit<SessionBindingContractEntry, "id">>;
|
||||
|
||||
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
|
||||
|
||||
|
||||
@@ -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> = {},
|
||||
): 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.
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
type LegacyConfigMigrationSpec,
|
||||
type LegacyConfigRule,
|
||||
} from "../../../config/legacy.shared.js";
|
||||
|
||||
function hasOwnKey(target: Record<string, unknown>, key: string): boolean {
|
||||
return Object.hasOwn(target, key);
|
||||
}
|
||||
import { hasOwnKey, visitChannelEntries } from "./legacy-config-record-shared.js";
|
||||
|
||||
function cleanupEmptyRecord(parent: Record<string, unknown>, 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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.<id>.threadBindings.ttlHours was renamed to channels.<id>.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.<id>.threadBindings.spawnSubagentSessions/spawnAcpSessions were replaced by channels.<id>.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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const providers = getRecord(tts.providers) ?? {};
|
||||
tts.providers = providers;
|
||||
@@ -192,35 +164,19 @@ function mergeLegacyTtsProviderConfig(
|
||||
tts: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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.<provider>. 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),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await new Promise<void>((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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
|
||||
@@ -176,10 +176,48 @@ async function startRuntime(
|
||||
};
|
||||
}
|
||||
|
||||
async function createWatchNodeFixture(
|
||||
prefix: string,
|
||||
options?: Parameters<typeof startRuntime>[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<Record<string, unknown>> {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function connectWatchNode(params: {
|
||||
baseUrl: string;
|
||||
identity: ReturnType<typeof loadOrCreateDeviceIdentity>;
|
||||
bootstrapToken?: string;
|
||||
deviceToken?: string;
|
||||
permissions?: ConnectParams["permissions"];
|
||||
}): Promise<Response> {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user