Compare commits

...

6 Commits

Author SHA1 Message Date
Peter Steinberger
bd13188f79 chore(release): prepare 2026.4.24 beta 1 2026-04-25 10:31:38 +01:00
Peter Steinberger
c2f6ad3876 fix: keep control ui bundle browser-safe 2026-04-25 10:01:44 +01:00
Peter Steinberger
bca479cefb fix: align browser profile facade exports 2026-04-25 09:32:50 +01:00
Peter Steinberger
6d082070a2 fix(plugins): preserve bundled cli metadata skip 2026-04-25 09:27:46 +01:00
Peter Steinberger
86f65ef1e3 docs(release): require changelog rewrite from commits 2026-04-25 09:16:12 +01:00
Peter Steinberger
cdd91edd5e fix(plugins): load packaged runtime mirrors from canonical sources 2026-04-25 09:16:05 +01:00
18 changed files with 1727 additions and 1456 deletions

View File

@@ -97,6 +97,11 @@ Use this skill for release and publish-time workflow. Keep ordinary development
## Build changelog-backed release notes
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
section from commit history, not just from existing notes: scan commits since
the last reachable release tag, add missed user-facing changes, dedupe
overlapping entries, and sort each section from most to least interesting for
users.
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
56ccee3ef8ff3b0ba7e2e765ae631b59254464585d5fef9db7e905f2c4c34ded plugin-sdk-api-baseline.json
39184cf8afaec691f0352d1a113e30a7099b87c0748237a3c7307e903ba24eee plugin-sdk-api-baseline.jsonl
97bbfec2665d3ea7b100eba09b1ebe3c2a678b79d3badfa5537cbe10ffb8c087 plugin-sdk-api-baseline.json
b9dc740d631641121696f5348a6cf34b82f7a7a3afae7ccbb0333fa0812bc61c plugin-sdk-api-baseline.jsonl

View File

@@ -1,5 +1,6 @@
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,

View File

@@ -35,6 +35,7 @@ import { DEFAULT_UPLOAD_DIR } from "./paths.js";
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,

View File

@@ -53,6 +53,7 @@ import {
beforeEach(async () => {
const page = {
on: vi.fn(),
goto,
title: pageTitle,
url: pageUrl,

View File

@@ -5,6 +5,12 @@ type QaWebSession = {
browser: Browser;
context: BrowserContext;
page: Page;
diagnostics: QaWebDiagnosticEntry[];
};
type QaWebDiagnosticEntry = {
kind: "console" | "pageerror" | "requestfailed";
text: string;
};
type QaWebOpenPageParams = {
@@ -44,6 +50,18 @@ type QaWebEvaluateParams = {
const sessions = new Map<string, QaWebSession>();
const DEFAULT_WEB_TIMEOUT_MS = 20_000;
const MAX_DIAGNOSTIC_ENTRIES = 50;
const MAX_DIAGNOSTIC_TEXT_CHARS = 2_000;
function appendDiagnostic(diagnostics: QaWebDiagnosticEntry[], entry: QaWebDiagnosticEntry): void {
diagnostics.push({
kind: entry.kind,
text: entry.text.slice(0, MAX_DIAGNOSTIC_TEXT_CHARS),
});
if (diagnostics.length > MAX_DIAGNOSTIC_ENTRIES) {
diagnostics.splice(0, diagnostics.length - MAX_DIAGNOSTIC_ENTRIES);
}
}
function resolveTimeoutMs(timeoutMs: number | undefined, fallbackMs = DEFAULT_WEB_TIMEOUT_MS) {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
@@ -71,12 +89,31 @@ export async function qaWebOpenPage(params: QaWebOpenPageParams) {
viewport: params.viewport ?? { width: 1440, height: 1080 },
});
const page = await context.newPage();
const diagnostics: QaWebDiagnosticEntry[] = [];
page.on("console", (message) => {
appendDiagnostic(diagnostics, {
kind: "console",
text: `[${message.type()}] ${message.text()}`,
});
});
page.on("pageerror", (error) => {
appendDiagnostic(diagnostics, {
kind: "pageerror",
text: error instanceof Error ? (error.stack ?? error.message) : String(error),
});
});
page.on("requestfailed", (request) => {
appendDiagnostic(diagnostics, {
kind: "requestfailed",
text: `${request.method()} ${request.url()} ${request.failure()?.errorText ?? "failed"}`,
});
});
await page.goto(params.url, {
waitUntil: "domcontentloaded",
timeout: timeoutMs,
});
const pageId = randomUUID();
sessions.set(pageId, { browser, context, page });
sessions.set(pageId, { browser, context, page, diagnostics });
return {
pageId,
url: page.url(),
@@ -128,6 +165,7 @@ export async function qaWebSnapshot(params: QaWebSnapshotParams) {
url: session.page.url(),
title: await session.page.title().catch(() => ""),
text: maxChars ? text.slice(0, maxChars) : text,
diagnostics: [...session.diagnostics],
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.24",
"version": "2026.4.24-beta.1",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -1129,14 +1129,14 @@
"types": "./dist/plugin-sdk/provider-usage.d.ts",
"default": "./dist/plugin-sdk/provider-usage.js"
},
"./plugin-sdk/web-content-extractor": {
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
"default": "./dist/plugin-sdk/web-content-extractor.js"
},
"./plugin-sdk/document-extractor": {
"types": "./dist/plugin-sdk/document-extractor.d.ts",
"default": "./dist/plugin-sdk/document-extractor.js"
},
"./plugin-sdk/web-content-extractor": {
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
"default": "./dist/plugin-sdk/web-content-extractor.js"
},
"./plugin-sdk/provider-web-fetch-contract": {
"types": "./dist/plugin-sdk/provider-web-fetch-contract.d.ts",
"default": "./dist/plugin-sdk/provider-web-fetch-contract.js"

View File

@@ -65,7 +65,7 @@ steps:
expr: "buildAgentSessionKey({ agentId: env.cfg.agents?.list?.find((agent) => agent.default)?.id ?? env.cfg.agents?.list?.[0]?.id ?? 'main', channel: 'qa-channel', accountId: 'default', peer: { kind: 'direct', id: config.conversationId }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: controlUiChatUrl
value:
expr: "(() => { const url = new URL(String(bootstrap.controlUiEmbeddedUrl)); url.pathname = `${url.pathname.replace(/\\/$/, '')}/chat`; url.searchParams.set('session', uiSessionKey); return url.toString(); })()"
expr: "(() => { const url = new URL(`${env.gateway.baseUrl}/`); url.searchParams.set('session', uiSessionKey); url.hash = `token=${encodeURIComponent(env.gateway.token ?? '')}`; return url.toString(); })()"
- call: webOpenPage
saveAs: uiTab
args:
@@ -80,17 +80,38 @@ steps:
args:
- pageId:
ref: uiPageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- call: waitForCondition
saveAs: uiReadySnapshot
args:
- lambda:
async: true
expr: "await (async () => { const snapshot = await webSnapshot({ pageId: uiPageId, maxChars: 12000, timeoutMs: liveTurnTimeoutMs(env, 30000) }); const text = normalizeLowercaseStringOrEmpty(snapshot.text); return text.includes('ready to chat') ? snapshot : undefined; })()"
- expr: liveTurnTimeoutMs(env, 45000)
- 500
- try:
actions:
- call: waitForCondition
saveAs: uiReadySnapshot
args:
- lambda:
async: true
expr: "await (async () => { const snapshot = await webSnapshot({ pageId: uiPageId, maxChars: 12000, timeoutMs: liveTurnTimeoutMs(env, 30000) }); const text = normalizeLowercaseStringOrEmpty(snapshot.text); return text.includes('ready to chat') ? snapshot : undefined; })()"
- expr: liveTurnTimeoutMs(env, 45000)
- 500
catch:
- call: webSnapshot
saveAs: uiReadyFailureSnapshot
args:
- pageId:
ref: uiPageId
maxChars: 12000
timeoutMs:
expr: liveTurnTimeoutMs(env, 15000)
- call: webEvaluate
saveAs: uiReadyFailureState
args:
- pageId:
ref: uiPageId
expression: "(() => { const app = document.querySelector('openclaw-app'); const resources = performance.getEntriesByType('resource').map((entry) => ({ name: entry.name, type: entry.initiatorType, duration: Math.round(entry.duration), transferSize: entry.transferSize, decodedBodySize: entry.decodedBodySize })); return { url: location.href, readyState: document.readyState, appDefined: Boolean(customElements.get('openclaw-app')), appState: app ? { sessionKey: app.sessionKey, settingsSessionKey: app.settings?.sessionKey, lastActiveSessionKey: app.settings?.lastActiveSessionKey, chatMessages: Array.isArray(app.chatMessages) ? app.chatMessages.length : null, chatLoading: app.chatLoading, lastError: app.lastError, connected: app.connected, tab: app.tab } : null, scripts: Array.from(document.scripts).map((script) => script.src || script.textContent?.slice(0, 80)), links: Array.from(document.querySelectorAll('link')).map((link) => link.href), resources, bodyHtml: document.body.innerHTML.slice(0, 400) }; })()"
timeoutMs:
expr: liveTurnTimeoutMs(env, 15000)
- throw:
expr: "`control ui did not become ready. state=${JSON.stringify(uiReadyFailureState)} diagnostics=${JSON.stringify(uiReadyFailureSnapshot.diagnostics ?? [])} snapshot: ${uiReadyFailureSnapshot.text}`"
- assert:
expr: "Boolean(uiPageId)"
message: control ui page was not available
@@ -149,7 +170,7 @@ steps:
args:
- pageId:
ref: uiAckPageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- try:
@@ -240,7 +261,7 @@ steps:
args:
- pageId:
ref: uiImagePageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- try:

View File

@@ -5,6 +5,7 @@ import {
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
readdirSync,
readFileSync,
rmSync,
@@ -17,6 +18,10 @@ import {
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
writePackageDistInventory,
} from "../src/infra/package-dist-inventory.ts";
import {
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageInstallRoot,
} from "../src/plugins/bundled-runtime-deps.ts";
import {
collectBundledExtensionManifestErrors,
type BundledExtension,
@@ -317,28 +322,48 @@ function bundledRuntimeDependencySentinelPath(
);
}
function bundledRuntimeDependencySentinelCandidates(
export function bundledRuntimeDependencySentinelCandidates(
packageRoot: string,
pluginId: string,
dependencyName: string,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const dependencyParts = dependencyName.split("/");
const packageRoots = [
packageRoot,
(() => {
try {
return realpathSync(packageRoot);
} catch {
return packageRoot;
}
})(),
];
const runtimeRoots = packageRoots.flatMap((root) => [
resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }),
resolveBundledRuntimeDependencyInstallRoot(join(root, "dist", "extensions", pluginId), {
env,
}),
]);
return [
bundledRuntimeDependencySentinelPath(packageRoot, pluginId, dependencyName),
join(packageRoot, "dist", "extensions", "node_modules", ...dependencyParts, "package.json"),
join(packageRoot, "node_modules", ...dependencyParts, "package.json"),
];
...runtimeRoots.map((root) => join(root, "node_modules", ...dependencyParts, "package.json")),
].filter((candidate, index, candidates) => candidates.indexOf(candidate) === index);
}
function assertBundledRuntimeDependencyAbsent(params: {
packageRoot: string;
pluginId: string;
dependencyName: string;
env?: NodeJS.ProcessEnv;
}): void {
const sentinelPath = bundledRuntimeDependencySentinelCandidates(
params.packageRoot,
params.pluginId,
params.dependencyName,
params.env,
).find((candidate) => existsSync(candidate));
if (sentinelPath) {
throw new Error(
@@ -351,11 +376,13 @@ function assertBundledRuntimeDependencyPresent(params: {
packageRoot: string;
pluginId: string;
dependencyName: string;
env?: NodeJS.ProcessEnv;
}): void {
const sentinelPath = bundledRuntimeDependencySentinelCandidates(
params.packageRoot,
params.pluginId,
params.dependencyName,
params.env,
).find((candidate) => existsSync(candidate));
if (sentinelPath) {
return;
@@ -413,24 +440,25 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str
{ pluginId: "feishu", dependencyName: "@larksuiteoapi/node-sdk" },
] as const;
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyAbsent({ packageRoot, ...dep });
}
const homeDir = join(tmpRoot, "activation-home");
mkdirSync(homeDir, { recursive: true });
const env = createPackedCliSmokeEnv(process.env, {
HOME: homeDir,
OPENAI_API_KEY: "sk-openclaw-release-check",
});
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyAbsent({ packageRoot, env, ...dep });
}
writePackedBundledPluginActivationConfig(homeDir);
execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], {
cwd: packageRoot,
stdio: "inherit",
env: createPackedCliSmokeEnv(process.env, {
HOME: homeDir,
OPENAI_API_KEY: "sk-openclaw-release-check",
}),
env,
});
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyPresent({ packageRoot, ...dep });
assertBundledRuntimeDependencyPresent({ packageRoot, env, ...dep });
}
}

View File

@@ -27959,6 +27959,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.24",
version: "2026.4.24-beta.1",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -128,6 +128,7 @@ const BROWSER_HELPER_EXPORT_PARITY_CONTRACTS: readonly BrowserHelperExportParity
extensionPath: "extensions/browser/browser-profiles.ts",
expectedExports: [
"DEFAULT_AI_SNAPSHOT_MAX_CHARS",
"DEFAULT_BROWSER_ACTION_TIMEOUT_MS",
"DEFAULT_BROWSER_DEFAULT_PROFILE_NAME",
"DEFAULT_BROWSER_EVALUATE_ENABLED",
"DEFAULT_OPENCLAW_BROWSER_COLOR",

View File

@@ -1504,6 +1504,60 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads bundled plugins from symlinked package roots with an external stage dir", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
const aliasRoot = path.join(makeTempDir(), "openclaw-alias");
const bundledDir = path.join(packageRoot, "dist", "extensions");
const plugin = writePlugin({
id: "alpha",
dir: path.join(bundledDir, "alpha"),
filename: "index.cjs",
body: `module.exports = { id: "alpha", register(api) { api.registerCommand({ name: "alpha", handler: () => "ok" }); } };`,
});
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/alpha",
version: "1.0.0",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "alpha",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.symlinkSync(packageRoot, aliasRoot, "dir");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(aliasRoot, "dist", "extensions");
process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir;
const registry = loadOpenClawPlugins({
cache: false,
config: { plugins: { enabled: true } },
});
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads bundled plugins with plugin-sdk imports from an external stage dir", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();

View File

@@ -2276,8 +2276,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
};
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
let runtimePluginRoot = pluginRoot;
let runtimeCandidateSource = candidate.source;
let runtimeSetupSource = manifestRecord.setupSource;
let runtimeCandidateSource =
candidate.origin === "bundled" ? safeRealpathOrResolve(candidate.source) : candidate.source;
let runtimeSetupSource =
candidate.origin === "bundled" && manifestRecord.setupSource
? safeRealpathOrResolve(manifestRecord.setupSource)
: manifestRecord.setupSource;
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
@@ -2381,12 +2385,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
runtimeCandidateSource =
remapBundledPluginRuntimePath({
source: candidate.source,
source: runtimeCandidateSource,
pluginRoot,
mirroredRoot: runtimePluginRoot,
}) ?? candidate.source;
}) ?? runtimeCandidateSource;
runtimeSetupSource = remapBundledPluginRuntimePath({
source: manifestRecord.setupSource,
source: runtimeSetupSource,
pluginRoot,
mirroredRoot: runtimePluginRoot,
});
@@ -3186,7 +3190,11 @@ export async function loadOpenClawPluginCliRegistry(
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
const sourceForCliMetadata =
candidate.origin === "bundled" ? cliMetadataSource : (cliMetadataSource ?? candidate.source);
candidate.origin === "bundled"
? cliMetadataSource
? safeRealpathOrResolve(cliMetadataSource)
: null
: (cliMetadataSource ?? candidate.source);
if (!sourceForCliMetadata) {
record.status = "loaded";
registry.plugins.push(record);

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -12,6 +12,7 @@ import {
collectBundledPluginRootRuntimeMirrorErrors,
collectForbiddenPackContentPaths,
collectInstalledBundledPluginRuntimeDepErrors,
bundledRuntimeDependencySentinelCandidates,
collectRootDistBundledRuntimeMirrors,
collectForbiddenPackPaths,
collectMissingPackPaths,
@@ -673,3 +674,36 @@ describe("collectInstalledBundledPluginRuntimeDepErrors", () => {
}
});
});
describe("bundledRuntimeDependencySentinelCandidates", () => {
it("checks canonical external runtime-deps roots for packed installs", () => {
const root = mkdtempSync(join(tmpdir(), "release-check-runtime-candidates-"));
const packageRoot = join(root, "package");
const aliasRoot = join(root, "package-alias");
const homeRoot = join(root, "home");
try {
mkdirSync(join(packageRoot, "dist", "extensions", "browser"), { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.24-beta.1" }, null, 2),
);
symlinkSync(packageRoot, aliasRoot, "dir");
const candidates = bundledRuntimeDependencySentinelCandidates(
aliasRoot,
"browser",
"playwright-core",
{ HOME: homeRoot } as NodeJS.ProcessEnv,
);
const externalCandidates = candidates.filter(
(candidate) =>
candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) &&
candidate.endsWith(join("node_modules", "playwright-core", "package.json")),
);
expect(externalCandidates.length).toBeGreaterThanOrEqual(2);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View File

@@ -1,10 +1,5 @@
import { html, nothing } from "lit";
import { applyMergePatch } from "../../../src/config/merge-patch.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat } from "./app-chat.ts";
@@ -120,9 +115,14 @@ import {
} from "./controllers/skills.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import "./components/dashboard-header.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import "./components/dashboard-header.ts";
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {

View File

@@ -1,6 +1,5 @@
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
@@ -80,6 +79,7 @@ import type {
import { importCustomThemeFromUrl } from "./custom-theme.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { resolveAgentIdFromSessionKey } from "./session-key.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import { loadLocalUserIdentity, loadSettings, type UiSettings } from "./storage.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";

View File

@@ -1,4 +1,3 @@
import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js";
import { resetToolStream } from "../app-tool-stream.ts";
import { extractText } from "../chat/message-extract.ts";
import { formatConnectError } from "../connect-error.ts";
@@ -12,6 +11,8 @@ import {
} from "./scope-errors.ts";
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
@@ -41,6 +42,97 @@ function shouldApplyChatHistoryResult(
function isSilentReplyStream(text: string): boolean {
return SILENT_REPLY_PATTERN.test(text);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripHeartbeatTokenForDisplay(
raw: string,
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
): { shouldSkip: boolean } {
let text = raw.trim();
if (!text) {
return { shouldSkip: true };
}
const strippedMarkup = text
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false };
}
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
let changed = true;
let didStrip = false;
text = strippedMarkup.trim();
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(HEARTBEAT_TOKEN)) {
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
didStrip = true;
changed = true;
continue;
}
if (tokenAtEnd.test(next)) {
const index = next.lastIndexOf(HEARTBEAT_TOKEN);
const before = next.slice(0, index).trimEnd();
const after = next.slice(index + HEARTBEAT_TOKEN.length).trimStart();
text = before ? `${before}${after}`.trimEnd() : "";
didStrip = true;
changed = true;
}
}
if (!didStrip) {
return { shouldSkip: false };
}
return { shouldSkip: !text || text.length <= maxAckChars };
}
function isHeartbeatOkResponse(message: { role: string; content?: unknown }): boolean {
if (message.role !== "assistant") {
return false;
}
const { text, hasNonTextContent } = resolveMessageText(message.content);
if (hasNonTextContent) {
return false;
}
return stripHeartbeatTokenForDisplay(text).shouldSkip;
}
function resolveMessageText(content: unknown): { text: string; hasNonTextContent: boolean } {
if (typeof content === "string") {
return { text: content, hasNonTextContent: false };
}
if (!Array.isArray(content)) {
return { text: "", hasNonTextContent: content != null };
}
let hasNonTextContent = false;
const text = content
.filter((block): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object" || !("type" in block)) {
hasNonTextContent = true;
return false;
}
if ((block as { type?: unknown }).type !== "text") {
hasNonTextContent = true;
return false;
}
if (typeof (block as { text?: unknown }).text !== "string") {
hasNonTextContent = true;
return false;
}
return true;
})
.map((block) => block.text)
.join("");
return { text, hasNonTextContent };
}
/** Client-side defense-in-depth: detect assistant messages whose text is purely NO_REPLY. */
function isAssistantSilentReply(message: unknown): boolean {
if (!message || typeof message !== "object") {