Merge remote-tracking branch 'upstream/main' into codex/pr-93446-conflict-repair

This commit is contained in:
fuller-stack-dev
2026-06-16 00:36:28 -06:00
97 changed files with 3235 additions and 314 deletions
+4
View File
@@ -23,12 +23,16 @@ Docs: https://docs.openclaw.ai
### Fixes
- Onboarding/skills: show the Homebrew install recommendation only on macOS and Linux, so FreeBSD and other unsupported platforms no longer get a misleading brew prompt. Fixes #68893; carries forward #68894, #68910, #68941, #68943, #69002, and #69545. Thanks @yurivict, @Sanjays2402, @Eruditi, @JustInCache, @nnish16, and @Mlightsnow.
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- iMessage: normalize leading NUL sent-message echo prefixes while preserving interior NUL bytes and the leading attributedBody marker handling from #73942. Carries forward #63581. Thanks @drvoss.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
@@ -306,6 +306,15 @@
"fps",
"screenIndex"
]
},
"screen_snapshot": {
"label": "screen snapshot",
"detailKeys": [
"node",
"nodeId",
"screenIndex",
"maxWidth"
]
}
}
},
+4
View File
@@ -111,6 +111,10 @@ After a successful startup, OpenClaw caches the bot identity in the state direct
## Access control and activation
### Group bot identity
In Telegram groups and forum topics, an explicit mention of the configured bot handle (for example `@my_bot`) is treated as addressing the selected OpenClaw agent, even when the agent persona name differs from the Telegram username. The group silence policy still applies to unrelated group traffic, but the bot handle itself is not considered "someone else."
<Tabs>
<Tab title="DM policy">
`channels.telegram.dmPolicy` controls direct message access:
+8
View File
@@ -1278,6 +1278,7 @@ Important examples:
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
Manifest metadata decides which provider/channel/setup choices appear in
@@ -1290,6 +1291,13 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
newer-but-valid values skip external plugins on older hosts. Bundled source
plugins are assumed to be co-versioned with the host checkout.
`openclaw.install.requiredPlatformPackages` is for npm packages that expose
required native binaries through optional, platform-specific aliases. List the
bare npm package name for every supported platform alias. During npm install,
OpenClaw verifies only the declared alias whose lockfile constraints match the
current host. If npm reports success but omits that alias, OpenClaw retries once
with a fresh cache and rolls back the install if the alias is still missing.
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
package was built against. It can be stricter than `minHostVersion` when a
+1
View File
@@ -163,6 +163,7 @@ Example:
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
| `requiredPlatformPackages` | `string[]` | Required platform-specific npm aliases verified during npm install. |
<AccordionGroup>
<Accordion title="Onboarding behavior">
+9 -1
View File
@@ -23,7 +23,15 @@
"install": {
"npmSpec": "@openclaw/codex",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.1-beta.1"
"minHostVersion": ">=2026.5.1-beta.1",
"requiredPlatformPackages": [
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64"
]
},
"compat": {
"pluginApi": ">=2026.6.2"
+13
View File
@@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j
type CodexPackageManifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
openclaw?: {
install?: {
requiredPlatformPackages?: string[];
};
};
};
describe("codex package manifest", () => {
@@ -18,5 +23,13 @@ describe("codex package manifest", () => {
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64",
]);
});
});
+2
View File
@@ -1602,6 +1602,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: agentSessionKey,
});
@@ -1779,6 +1780,7 @@ export async function handleFeishuMessage(params: {
threadReply,
accountId: account.accountId,
identity,
mentionTargets: ctx.mentionTargets,
messageCreateTimeMs,
sessionKey: route.sessionKey,
});
+10 -5
View File
@@ -549,18 +549,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("does not attach automatic mentions to non-streaming plain text replies", async () => {
it("passes mention-forward targets to non-streaming plain text replies without rewriting body text", async () => {
useNonStreamingAutoAccount();
const { options } = createDispatcherHarness({
replyToMessageId: "om_msg",
mentionTargets: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
await options.deliver({ text: "plain text" }, { kind: "final" });
await options.deliver(
{ text: 'plain text <at user_id="ou_body">Body User</at>' },
{ kind: "final" },
);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
"mentions",
);
expectMockArgFields(sendMessageFeishuMock, "message send params", {
text: 'plain text <at user_id="ou_body">Body User</at>',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
});
it("does not attach automatic mentions to card replies", async () => {
+7 -1
View File
@@ -15,6 +15,7 @@ import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js";
import type { MentionTarget } from "./mention-target.types.js";
import {
createReplyPrefixContext,
type ClawdbotConfig,
@@ -129,6 +130,7 @@ type CreateFeishuReplyDispatcherParams = {
rootId?: string;
accountId?: string;
identity?: OutboundIdentity;
mentionTargets?: MentionTarget[];
/** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number;
@@ -149,6 +151,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId,
accountId,
identity,
mentionTargets,
} = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const typingTargetMessageId = explicitTypingTargetMessageId?.trim() || replyToMessageId;
@@ -743,7 +746,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text,
useCard: false,
infoKind: info?.kind,
sendChunk: async ({ chunk }) => {
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
@@ -752,6 +755,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
replyInThread: effectiveReplyInThread,
allowTopLevelReplyFallback,
accountId,
...(info?.kind === "final" && isFirst && mentionTargets?.length
? { mentions: mentionTargets }
: {}),
});
},
});
+89 -1
View File
@@ -1,7 +1,7 @@
// Feishu tests cover send plugin behavior.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
import { buildMarkdownCard } from "./send.js";
import { buildFeishuPostMessagePayload, buildMarkdownCard } from "./send.js";
const {
mockConvertMarkdownTables,
@@ -64,6 +64,49 @@ let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessage
let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate;
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
describe("buildFeishuPostMessagePayload", () => {
it("prepends structured mention targets as native post at elements", () => {
const payload = buildFeishuPostMessagePayload({
messageText: "hello **world**",
mentions: [
{ openId: "ou_alice", name: "Alice", key: "@_user_1" },
{ openId: " ou_bob ", name: " Bob ", key: "@_user_2" },
],
});
expect(payload.msgType).toBe("post");
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_alice", user_name: "Alice" },
{ tag: "at", user_id: "ou_bob", user_name: "Bob" },
{ tag: "md", text: "hello **world**" },
],
],
},
});
});
it("leaves body-supplied at tags literal in the markdown element", () => {
const payload = buildFeishuPostMessagePayload({
messageText: 'please keep <at user_id="ou_body">Body User</at> literal',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
expect(JSON.parse(payload.content)).toEqual({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
{ tag: "md", text: 'please keep <at user_id="ou_body">Body User</at> literal' },
],
],
},
});
});
});
describe("getMessageFeishu", () => {
beforeAll(async () => {
({
@@ -173,6 +216,51 @@ describe("getMessageFeishu", () => {
});
});
it("sends automatic mentions as native post elements without rewriting body text", async () => {
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_mentions" } });
mockCreateFeishuClient.mockReturnValue({
im: {
message: {
create,
reply: vi.fn(),
get: mockClientGet,
list: mockClientList,
patch: mockClientPatch,
},
},
});
const result = await sendMessageFeishu({
cfg: {} as ClawdbotConfig,
to: "oc_send",
text: 'body <at user_id="ou_body">Body User</at>',
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
});
expect(mockConvertMarkdownTables).toHaveBeenCalledWith(
'body <at user_id="ou_body">Body User</at>',
"preserve",
);
expect(create).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: {
receive_id: "oc_send",
msg_type: "post",
content: JSON.stringify({
zh_cn: {
content: [
[
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
{ tag: "md", text: 'body <at user_id="ou_body">Body User</at>' },
],
],
},
}),
},
});
expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" });
});
it("extracts text content from interactive card elements", async () => {
mockClientGet.mockResolvedValueOnce({
code: 0,
+41 -18
View File
@@ -12,7 +12,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { parsePostContent } from "./post.js";
import {
assertFeishuMessageApiSuccess,
@@ -546,22 +546,50 @@ export type SendFeishuMessageParams = {
accountId?: string;
};
export function buildFeishuPostMessagePayload(params: { messageText: string }): {
type FeishuPostMessageElement =
| { tag: "at"; user_id: string; user_name?: string }
| { tag: "md"; text: string };
function buildFeishuPostMentionElements(mentions?: MentionTarget[]): FeishuPostMessageElement[] {
if (!mentions?.length) {
return [];
}
const elements: FeishuPostMessageElement[] = [];
for (const mention of mentions) {
const userId = mention.openId.trim();
if (!userId) {
continue;
}
const userName = mention.name.trim();
elements.push({
tag: "at",
user_id: userId,
...(userName ? { user_name: userName } : {}),
});
}
return elements;
}
export function buildFeishuPostMessagePayload(params: {
messageText: string;
mentions?: MentionTarget[];
}): {
content: string;
msgType: string;
} {
const { messageText } = params;
const { messageText, mentions } = params;
const content: FeishuPostMessageElement[] = [
...buildFeishuPostMentionElements(mentions),
{
tag: "md",
text: messageText,
},
];
return {
content: JSON.stringify({
zh_cn: {
content: [
[
{
tag: "md",
text: messageText,
},
],
],
content: [content],
},
}),
msgType: "post",
@@ -587,14 +615,9 @@ export async function sendMessageFeishu(
channel: "feishu",
});
// Build message content (with @mention support)
let rawText = text ?? "";
if (mentions && mentions.length > 0) {
rawText = buildMentionedMessage(mentions, rawText);
}
const messageText = convertMarkdownTables(rawText, tableMode);
const messageText = convertMarkdownTables(text ?? "", tableMode);
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
const { content, msgType } = buildFeishuPostMessagePayload({ messageText, mentions });
const directParams = { receiveId, receiveIdType, content, msgType };
return sendReplyOrFallbackDirect(client, {
+17 -6
View File
@@ -34,17 +34,28 @@ export type SentMessageCache = {
// duplicate delivery (noisy but not lossy) — never message loss.
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
const LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS = /^[\uFEFF\uFFFD\uFFFE\uFFFF]+/u;
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
return (
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
);
}
function stripLeadingEchoTextCorruptionMarkers(text: string): string {
let offset = 0;
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
offset += 1;
}
return offset === 0 ? text : text.slice(offset);
}
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;
}
const normalized = text
.replace(/\r\n?/g, "\n")
.trim()
.replace(LEADING_ATTRIBUTED_BODY_CORRUPTION_MARKERS, "")
.trim();
const normalized = stripLeadingEchoTextCorruptionMarkers(
text.replace(/\r\n?/g, "\n").trim(),
).trim();
return normalized ? normalized : null;
}
@@ -51,6 +51,20 @@ describe("iMessage sent-message echo cache", () => {
).toBe(true);
});
it("matches delayed reflected echoes with leading NUL corruption markers", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
expect(
cache.has("acct:imessage:+1555", {
text: "\u0000\u0000Delayed echo reply",
}),
).toBe(true);
});
it("keeps attributedBody corruption cleanup leading-only", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
@@ -67,6 +81,16 @@ describe("iMessage sent-message echo cache", () => {
expect(cache.has("acct:imessage:+1555", { text: "Delayed\necho reply" })).toBe(false);
});
it("keeps NUL corruption cleanup leading-only", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
const cache = createSentMessageCache();
cache.remember("acct:imessage:+1555", { text: "Delayed echo reply" });
expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
});
it("matches by outbound message id and ignores placeholder ids", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
+8 -2
View File
@@ -884,8 +884,14 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
const state =
embeddingProbe.ok && embeddingProbe.checked === false
? "skipped"
: embeddingProbe.ok
? "ready"
: "unavailable";
const stateColor =
state === "skipped" ? theme.muted : embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
+36
View File
@@ -677,6 +677,42 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("does not report qmd lexical search mode as embedding unavailable", async () => {
const close = vi.fn(async () => {});
const probeVectorStoreAvailability = vi.fn(async () => true);
const probeVectorAvailability = vi.fn(async () => false);
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true, checked: false }));
mockManager({
probeVectorStoreAvailability,
probeVectorAvailability,
probeEmbeddingAvailability,
status: () =>
makeMemoryStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
vector: {
enabled: false,
semanticAvailable: false,
available: false,
},
}),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["status", "--deep"]);
expect(probeVectorStoreAvailability).not.toHaveBeenCalled();
expect(probeVectorAvailability).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expectLogged(log, "Vector: disabled");
expectLogged(log, "Embeddings: skipped");
expectNotLogged(log, "Embeddings error:");
expect(close).toHaveBeenCalled();
});
it("prints recall-store audit details during status", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
@@ -1616,6 +1616,46 @@ describe("memory index", () => {
);
});
it("bounds per-keyword FTS fallback in provider-backed hybrid search", async () => {
const cfg = createCfg({
storePath: indexMainPath,
minScore: 0.35,
hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 },
});
const manager = await getPersistentManager(cfg);
await manager.sync({ reason: "test" });
const db = (
manager as unknown as {
db: {
prepare: (sql: string) => unknown;
};
}
).db;
const originalPrepare = db.prepare.bind(db);
let ftsSelects = 0;
const prepareSpy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
if (sql.includes("FROM chunks_fts") && sql.includes("WHERE chunks_fts MATCH ?")) {
ftsSelects += 1;
}
return originalPrepare(sql);
});
try {
const results = await manager.search(
"zebra project router gateway session transcript approval command owner workspace token budget retry queue",
{ maxResults: 5 },
);
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.path).toContain("memory/2026-01-12.md");
expect(ftsSelects).toBeGreaterThan(1);
expect(ftsSelects).toBeLessThanOrEqual(7);
} finally {
prepareSpy.mockRestore();
}
});
it("reports vector availability after probe", async () => {
const cfg = createCfg({ storePath: indexVectorPath, vectorEnabled: true });
const manager = await getPersistentManager(cfg);
+66 -45
View File
@@ -71,6 +71,7 @@ const FTS_TABLE = "chunks_fts";
const EMBEDDING_CACHE_TABLE = "embedding_cache";
const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol.for("openclaw.memoryIndexManagerCache");
export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000;
const KEYWORD_FALLBACK_SEARCH_TERM_LIMIT = 6;
const log = createSubsystemLogger("memory");
type MemoryIndexManagerPurpose = "default" | "status" | "cli";
type MemoryEmbeddingProviderRequirement = {
@@ -88,6 +89,8 @@ type EmbeddingProbeCacheEntry = {
expireAtMs: number;
};
type KeywordSearchHit = MemorySearchResult & { id: string; textScore: number };
const EMBEDDING_PROBE_CACHE = new Map<string, EmbeddingProbeCacheEntry>();
export async function closeAllMemoryIndexManagers(): Promise<void> {
@@ -689,7 +692,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return [];
}
const fullQueryResults = await this.searchKeyword(
const keywordResults = await this.searchKeywordWithFallback(
cleaned,
candidates,
{
@@ -700,47 +703,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
log.warn(`memory search: FTS keyword query failed: ${formatErrorMessage(err)}`);
return [];
});
const resultSets =
fullQueryResults.length > 0
? [fullQueryResults]
: await Promise.all(
// Fallback: broaden recall for conversational queries when the
// exact AND query is too strict to return any results.
(() => {
const keywords = extractKeywords(cleaned, {
ftsTokenizer: this.settings.store.fts.tokenizer,
});
const searchTerms = keywords.length > 0 ? keywords : [cleaned];
return searchTerms.map((term) =>
this.searchKeyword(
term,
candidates,
{ boostFallbackRanking: true },
sourceFilterList,
).catch((err: unknown) => {
log.warn(
`memory search: FTS per-keyword query failed for "${term}": ${formatErrorMessage(err)}`,
);
return [];
}),
);
})(),
);
// Merge and deduplicate results, keeping highest score for each chunk
const seenIds = new Map<string, (typeof resultSets)[0][0]>();
for (const results of resultSets) {
for (const result of results) {
const existing = seenIds.get(result.id);
if (!existing || result.score > existing.score) {
seenIds.set(result.id, result);
}
}
}
const merged = [...seenIds.values()];
const decayed = await applyTemporalDecayToHybridResults({
results: merged,
results: keywordResults,
temporalDecay: hybrid.temporalDecay,
workspaceDir: this.workspaceDir,
});
@@ -751,7 +716,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
const loadKeywordResults = async () =>
hybrid.enabled && this.fts.enabled && this.fts.available
? await this.searchKeyword(
? await this.searchKeywordWithFallback(
cleaned,
candidates,
{ boostFallbackRanking: true },
@@ -824,8 +789,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
// Hybrid defaults can produce keyword-only matches below minScore after
// weighting. If strict vector+keyword results are empty, preserve the FTS
// matches; FTS already established lexical relevance.
// BM25 normalization and textWeight scaling. Preserve FTS-backed lexical
// hits when they are the only relevant results.
const relaxedMinScore = 0;
const keywordKeys = new Set(
keywordResults.map(
@@ -910,7 +875,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
limit: number,
options?: { boostFallbackRanking?: boolean },
sourceFilterList?: MemorySource[],
): Promise<Array<MemorySearchResult & { id: string; textScore: number }>> {
): Promise<KeywordSearchHit[]> {
if (!this.fts.enabled || !this.fts.available) {
return [];
}
@@ -927,7 +892,63 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
bm25RankToScore,
boostFallbackRanking: options?.boostFallbackRanking,
});
return results.map((entry) => entry as MemorySearchResult & { id: string; textScore: number });
return results.map((entry) => entry as KeywordSearchHit);
}
private async searchKeywordWithFallback(
query: string,
limit: number,
options: { boostFallbackRanking?: boolean } | undefined,
sourceFilterList: MemorySource[],
): Promise<KeywordSearchHit[]> {
const fullQueryResults = await this.searchKeyword(
query,
limit,
options,
sourceFilterList,
).catch(() => []);
if (fullQueryResults.length > 0) {
return fullQueryResults;
}
// Broaden recall for conversational queries when the exact AND query is too
// strict, but cap the number of extra FTS probes so long prompts cannot fan
// out into unbounded sqlite work.
const fallbackTerms = this.resolveKeywordFallbackTerms(query);
if (fallbackTerms.length === 0) {
return [];
}
const resultSets = await Promise.all(
fallbackTerms.map((term) =>
this.searchKeyword(term, limit, options, sourceFilterList).catch(() => []),
),
);
return this.mergeKeywordSearchHits(resultSets);
}
private resolveKeywordFallbackTerms(query: string): string[] {
const keywords = extractKeywords(query, {
ftsTokenizer: this.settings.store.fts.tokenizer,
}).filter((term) => term !== query);
return keywords.slice(0, KEYWORD_FALLBACK_SEARCH_TERM_LIMIT);
}
private mergeKeywordSearchHits(resultSets: KeywordSearchHit[][]): KeywordSearchHit[] {
const seenIds = new Map<string, KeywordSearchHit>();
for (const results of resultSets) {
for (const result of results) {
const existing = seenIds.get(result.id);
if (
!existing ||
result.textScore > existing.textScore ||
(result.textScore === existing.textScore && result.score > existing.score)
) {
seenIds.set(result.id, result);
}
}
}
return [...seenIds.values()].toSorted((a, b) => b.score - a.score);
}
private mergeHybridResults(params: {
@@ -6062,8 +6062,8 @@ describe("QmdMemoryManager", () => {
await expect(manager.probeVectorAvailability()).resolves.toBe(false);
await expect(manager.probeEmbeddingAvailability()).resolves.toEqual({
ok: false,
error: "QMD semantic vectors are unavailable",
ok: true,
checked: false,
});
expect(spawnMock.mock.calls.length).toBe(baselineCalls);
expect(manager.status().vector).toEqual({
@@ -1566,6 +1566,9 @@ export class QmdMemoryManager implements MemorySearchManager {
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
if (!qmdUsesVectors(this.qmd.searchMode)) {
return { ok: true, checked: false };
}
const ok = await this.probeVectorAvailability();
return {
ok,
@@ -0,0 +1,182 @@
// Ollama tests cover doctor contract config compatibility.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
type ModelDefinition = NonNullable<
NonNullable<OpenClawConfig["models"]>["providers"]
>[string]["models"][number];
const cloudModel: ModelDefinition = {
id: "kimi-k2.5:cloud",
name: "Kimi K2.5 Cloud",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
};
function readOllamaCloudProvider(config: OpenClawConfig): Record<string, unknown> | undefined {
return config.models?.providers?.["ollama-cloud"] as Record<string, unknown> | undefined;
}
describe("ollama doctor contract", () => {
it("detects retired Ollama Cloud provider endpoints", () => {
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ai.ollama.com" })).toBe(true);
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ollama.com" })).toBe(false);
});
it("migrates retired Ollama Cloud provider baseUrl to the canonical endpoint", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
api: "ollama",
models: [cloudModel],
},
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseUrl from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [cloudModel],
});
expect(readOllamaCloudProvider(config)?.baseUrl).toBe("https://ai.ollama.com");
});
it("removes retired Ollama Cloud provider baseURL aliases when canonical baseUrl is present", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("migrates retired Ollama Cloud provider baseURL aliases when canonical baseUrl is blank", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseURL from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("preserves custom canonical baseUrl when removing retired baseURL aliases", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("does not expose credentials or query parameters from the retired URL", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://user:password@ai.ollama.com/?token=secret",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes.join("\n")).not.toContain("user");
expect(result.changes.join("\n")).not.toContain("password");
expect(result.changes.join("\n")).not.toContain("secret");
expect(readOllamaCloudProvider(result.config)?.baseUrl).toBe("https://ollama.com");
});
});
+1
View File
@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
+103
View File
@@ -0,0 +1,103 @@
// Ollama helper module supports config compat behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js";
type LegacyConfigRule = {
path: Array<string | number>;
message: string;
match: (value: unknown) => boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isRetiredOllamaCloudBaseUrl(value: unknown): value is string {
if (typeof value !== "string" || !value.trim()) {
return false;
}
try {
return new URL(value.trim()).hostname.toLowerCase() === "ai.ollama.com";
} catch {
return false;
}
}
function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null {
const record = asRecord(provider);
if (!record) {
return null;
}
if (isRetiredOllamaCloudBaseUrl(record.baseUrl)) {
return { key: "baseUrl" };
}
if (isRetiredOllamaCloudBaseUrl(record.baseURL)) {
return { key: "baseURL" };
}
return null;
}
export const legacyConfigRules: LegacyConfigRule[] = [
{
path: ["models", "providers", OLLAMA_CLOUD_PROVIDER_ID],
message:
'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".',
match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null,
},
];
export function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const provider = config.models?.providers?.[OLLAMA_CLOUD_PROVIDER_ID];
const retired = findRetiredOllamaCloudBaseUrl(provider);
if (!retired) {
return null;
}
const nextConfig = structuredClone(config);
const nextModels = asRecord(nextConfig.models) ?? {};
nextConfig.models = nextModels as OpenClawConfig["models"];
const nextProviders = asRecord(nextModels.providers) ?? {};
nextModels.providers = nextProviders;
const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {};
nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider;
const canonicalBaseUrl = nextProvider.baseUrl;
if (
retired.key === "baseURL" &&
typeof canonicalBaseUrl === "string" &&
canonicalBaseUrl.trim() &&
!isRetiredOllamaCloudBaseUrl(canonicalBaseUrl)
) {
delete nextProvider.baseURL;
return {
config: nextConfig,
changes: [
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
],
};
}
nextProvider.baseUrl = OLLAMA_CLOUD_BASE_URL;
if (retired.key === "baseURL") {
delete nextProvider.baseURL;
}
return {
config: nextConfig,
changes: [
`Updated models.providers.ollama-cloud.${retired.key} from the retired Ollama Cloud endpoint to ${OLLAMA_CLOUD_BASE_URL}.`,
],
};
}
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] };
}
@@ -6,6 +6,7 @@ import {
logInboundDrop,
matchesMentionWithExplicit,
resolveInboundMentionDecision,
type BuildChannelInboundEventContextParams,
type BuildMentionRegexesOptions,
type NormalizedLocation,
} from "openclaw/plugin-sdk/channel-inbound";
@@ -50,6 +51,9 @@ import { resolveTelegramCommandIngressAuthorization } from "./ingress.js";
type StickerVisionRuntime = typeof import("./sticker-vision.runtime.js");
type MediaUnderstandingRuntime = typeof import("./media-understanding.runtime.js");
type TelegramMentionFacts = NonNullable<
NonNullable<BuildChannelInboundEventContextParams["access"]>["mentions"]
>;
let stickerVisionRuntimePromise: Promise<StickerVisionRuntime> | undefined;
let mediaUnderstandingRuntimePromise: Promise<MediaUnderstandingRuntime> | undefined;
@@ -70,6 +74,7 @@ export type TelegramInboundBodyResult = {
historyKey?: string;
commandAuthorized: boolean;
effectiveWasMentioned: boolean;
mentionFacts: TelegramMentionFacts;
canDetectMention: boolean;
shouldBypassMention: boolean;
hasControlCommand: boolean;
@@ -120,6 +125,39 @@ function formatSavedMediaPlaceholder(allMedia: TelegramMediaRef[]): string | und
return `<media:document> (${allMedia.length} attachments)`;
}
function resolveTelegramMentionFacts(params: {
canDetectMention: boolean;
effectiveWasMentioned: boolean;
explicitlyMentionedBot: boolean;
computedWasMentioned: boolean;
implicitMentionKinds: TelegramMentionFacts["implicitMentionKinds"];
requireMention: boolean;
shouldBypassMention: boolean;
shouldSkip: boolean;
}): TelegramMentionFacts {
let mentionSource: TelegramMentionFacts["mentionSource"];
if (params.explicitlyMentionedBot) {
mentionSource = "explicit_bot";
} else if (params.computedWasMentioned) {
mentionSource = "mention_pattern";
} else if (params.implicitMentionKinds && params.implicitMentionKinds.length > 0) {
mentionSource = "implicit_thread";
} else if (params.shouldBypassMention) {
mentionSource = "command_bypass";
}
return {
canDetectMention: params.canDetectMention,
wasMentioned: params.effectiveWasMentioned,
explicitlyMentionedBot: params.explicitlyMentionedBot,
mentionSource,
implicitMentionKinds: params.implicitMentionKinds,
effectiveWasMentioned: params.effectiveWasMentioned,
requireMention: params.requireMention,
shouldSkip: params.shouldSkip,
};
}
async function resolveStickerVisionSupport(params: {
cfg: OpenClawConfig;
agentId?: string;
@@ -442,6 +480,16 @@ export async function resolveTelegramInboundBody(params: {
historyKey,
commandAuthorized,
effectiveWasMentioned,
mentionFacts: resolveTelegramMentionFacts({
canDetectMention,
effectiveWasMentioned,
explicitlyMentionedBot: explicitlyMentioned,
computedWasMentioned,
implicitMentionKinds,
requireMention: Boolean(requireMention),
shouldBypassMention: mentionDecision.shouldBypassMention,
shouldSkip: mentionDecision.shouldSkip,
}),
canDetectMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
hasControlCommand: hasControlCommandInMessage,
@@ -106,6 +106,8 @@ describe("buildTelegramMessageContext implicitMention forum service messages", (
// Real bot reply → implicitMention fires → message is NOT skipped.
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.WasMentioned).toBe(true);
expect(ctx?.ctxPayload?.MentionSource).toBe("implicit_thread");
expect(ctx?.ctxPayload?.ImplicitMentionKinds).toEqual(["reply_to_bot"]);
});
it("DOES trigger implicitMention for bot media messages with caption", async () => {
@@ -1,6 +1,7 @@
// Telegram plugin module implements bot message context.session behavior.
import path from "node:path";
import {
type BuildChannelInboundEventContextParams,
type BuildChannelInboundEventContextAsyncParams,
type BuiltChannelInboundEventContext,
classifyChannelInboundEvent,
@@ -33,6 +34,10 @@ import type {
TelegramMessageContextSessionRuntimeOverrides,
TelegramPromptContextEntry,
} from "./bot-message-context.types.js";
type TelegramMentionFacts = NonNullable<
NonNullable<BuildChannelInboundEventContextParams["access"]>["mentions"]
>;
import {
buildGroupLabel,
buildSenderLabel,
@@ -220,6 +225,7 @@ export async function buildTelegramInboundContextPayload(params: {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
effectiveWasMentioned: boolean;
mentionFacts: TelegramMentionFacts;
hasControlCommand: boolean;
stickerCacheHit?: boolean;
audioTranscribedMediaIndex?: number;
@@ -270,6 +276,7 @@ export async function buildTelegramInboundContextPayload(params: {
groupConfig,
topicConfig,
effectiveWasMentioned,
mentionFacts,
hasControlCommand,
stickerCacheHit,
audioTranscribedMediaIndex,
@@ -544,6 +551,7 @@ export async function buildTelegramInboundContextPayload(params: {
commands: {
authorized: commandAuthorized,
},
mentions: mentionFacts,
},
command:
commandSource === "native"
@@ -11,6 +11,13 @@ const inboundBodyMock = vi.hoisted(() =>
historyKey: undefined,
commandAuthorized: false,
effectiveWasMentioned: false,
mentionFacts: {
canDetectMention: true,
wasMentioned: false,
effectiveWasMentioned: false,
requireMention: false,
shouldSkip: false,
},
canDetectMention: true,
shouldBypassMention: false,
hasControlCommand: false,
@@ -477,7 +477,7 @@ export const buildTelegramMessageContext = async ({
groupConfig,
topicConfig,
providerMentionPatterns: cfg.channels?.telegram?.accounts?.[account.accountId]?.mentionPatterns,
requireMention,
requireMention: Boolean(requireMention),
options,
groupHistories,
historyLimit,
@@ -533,6 +533,7 @@ export const buildTelegramMessageContext = async ({
groupConfig,
topicConfig,
effectiveWasMentioned: bodyResult.effectiveWasMentioned,
mentionFacts: bodyResult.mentionFacts,
hasControlCommand: bodyResult.hasControlCommand,
stickerCacheHit: bodyResult.stickerCacheHit,
...(bodyResult.audioTranscribedMediaIndex !== undefined
@@ -3758,6 +3758,37 @@ describe("createTelegramBot", () => {
}
}
});
it("marks explicit Telegram bot-handle mentions in the inbound context", async () => {
resetHarnessSpies();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
});
await dispatchMessage({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "@openclaw_bot status",
entities: [{ type: "mention", offset: 0, length: "@openclaw_bot".length }],
date: 1736380800,
message_id: 4,
from: { id: 9, first_name: "Ada" },
},
me: { id: 999, username: "openclaw_bot" },
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
expect(payload.WasMentioned).toBe(true);
expect(payload.ExplicitlyMentionedBot).toBe(true);
expect(payload.MentionSource).toBe("explicit_bot");
expect(payload.BotUsername).toBe("openclaw_bot");
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
resetHarnessSpies();
+8 -1
View File
@@ -14,6 +14,7 @@ import {
parseProvider,
readPositiveIntEnv,
modelProviderConfigBatchJson,
posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
repoRoot,
resolveParallelsModelTimeoutSeconds,
@@ -741,7 +742,8 @@ rm -f "$provider_config_batch"`);
this.restrictAgentTurnPlugins();
this.prepareAgentWorkspace();
this.guestBash(
`agent_ok=false
`${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id="parallels-linux-smoke"
if [ "$attempt" -gt 1 ]; then session_id="parallels-linux-smoke-retry-$attempt"; fi
@@ -755,6 +757,11 @@ for attempt in 1 2; do
set -e
cat "$output_file"
if [ "$rc" -ne 0 ]; then
if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
rm -f "$output_file"
echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
fi
rm -f "$output_file"
exit "$rc"
fi
+7
View File
@@ -16,6 +16,7 @@ import {
parseMode,
parseProvider,
modelProviderConfigBatchJson,
posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
parsePositiveInt,
readPositiveIntEnv,
@@ -1108,6 +1109,7 @@ rm -f "$provider_config_batch"`);
this.restrictAgentTurnPlugins();
this.guestSh(
`${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")}
${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id="parallels-macos-smoke"
@@ -1122,6 +1124,11 @@ for attempt in 1 2; do
set -e
cat "$output_file"
if [ "$rc" -ne 0 ]; then
if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
rm -f "$output_file"
echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
fi
rm -f "$output_file"
exit "$rc"
fi
+19 -3
View File
@@ -1,7 +1,11 @@
// Npm Update Scripts script supports OpenClaw repository automation.
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import { shellQuote } from "./host-command.ts";
import { posixProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
import {
posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
windowsCodexPlatformPackageRepairFunction,
} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
@@ -72,6 +76,7 @@ function posixAssertAgentOkScript(command: string, input: NpmUpdateScriptInput,
fallbackPluginId: input.auth.modelId.split("/", 1)[0] || "openai",
modelId: input.auth.modelId,
})}
${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id=${shellQuote(sessionId)}
@@ -84,6 +89,11 @@ for attempt in 1 2; do
set -e
print_log_tail "$output_file"
if [ "$rc" -ne 0 ]; then
if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
rm -f "$output_file"
echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
fi
rm -f "$output_file"
exit "$rc"
fi
@@ -138,6 +148,7 @@ Wait-OpenClawGateway`;
function windowsAssertAgentOkScript(input: NpmUpdateScriptInput): string {
return `${windowsAgentTurnConfigPatchScript(input.auth.modelId)}
${windowsCodexPlatformPackageRepairFunction()}
$sessionPath = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions\\parallels-npm-update-windows.jsonl'
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
@@ -149,16 +160,21 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
$sessionPath = Join-Path $sessionsDir "$sessionId.jsonl"
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
$output = Invoke-OpenClaw agent --local --agent main --session-id $sessionId --model ${psSingleQuote(input.auth.modelId)} --message 'Reply with exact ASCII text OK only.' --thinking off --timeout ${resolveParallelsModelTimeoutSeconds("windows")} --json 2>&1
$agentExitCode = $LASTEXITCODE
if ($null -ne $output) { $output | ForEach-Object { $_ } }
if ($LASTEXITCODE -ne 0) { throw "agent failed with exit code $LASTEXITCODE" }
if (($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
if ($agentExitCode -eq 0 -and ($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
$agentOk = $true
break
}
if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
}
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt finished without OK response; retrying"
Start-Sleep -Seconds 3
}
if ($agentExitCode -ne 0) { throw "agent failed with exit code $agentExitCode" }
}
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`;
}
+78
View File
@@ -9,6 +9,84 @@ interface PluginIsolationOptions {
nodeCommand?: string;
}
export function posixCodexPlatformPackageRepairFunction(): string {
return `repair_missing_codex_platform_package() {
output_file="$1"
grep -F 'Missing optional dependency @openai/codex-' "$output_file" >/dev/null 2>&1 || return 1
state_home="\${OPENCLAW_PARALLELS_HOME:-\${HOME:-}}"
codex_manifest=""
for candidate in "$state_home"/.openclaw/npm/projects/*/node_modules/@openclaw/codex/package.json; do
[ -f "$candidate" ] || continue
codex_manifest="$candidate"
break
done
if [ -z "$codex_manifest" ]; then
echo "codex-platform-repair: managed Codex project not found" >&2
return 1
fi
project_root="\${codex_manifest%/node_modules/@openclaw/codex/package.json}"
cache_dir="$(mktemp -d "\${TMPDIR:-/tmp}/openclaw-npm-cache.XXXXXX")"
echo "codex-platform-repair: retrying managed npm install once with a fresh cache" >&2
repair_rc=0
(
cd "$project_root"
NPM_CONFIG_CACHE="$cache_dir" npm_config_cache="$cache_dir" npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
) || repair_rc=$?
rm -rf "$cache_dir"
if [ "$repair_rc" -ne 0 ]; then
echo "codex-platform-repair: npm install failed with exit code $repair_rc" >&2
return "$repair_rc"
fi
echo "codex-platform-repair: managed npm install completed" >&2
}`;
}
export function windowsCodexPlatformPackageRepairFunction(): string {
return String.raw`function Repair-MissingCodexPlatformPackage {
param([object[]] $Output)
$outputText = $Output | Out-String
if ($outputText -notmatch [regex]::Escape('Missing optional dependency @openai/codex-')) {
return $false
}
$projectsRoot = Join-Path $env:USERPROFILE '.openclaw\npm\projects'
$codexManifest = Get-ChildItem -Path $projectsRoot -Filter package.json -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match 'node_modules[\\/]@openclaw[\\/]codex[\\/]package\.json$' } |
Select-Object -First 1
if (-not $codexManifest) {
Write-Warning 'codex-platform-repair: managed Codex project not found'
return $false
}
$projectRoot = $codexManifest.Directory.Parent.Parent.Parent.FullName
$cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) ('openclaw-npm-cache-' + [guid]::NewGuid().ToString('N'))
$oldUpperCache = [Environment]::GetEnvironmentVariable('NPM_CONFIG_CACHE', 'Process')
$oldLowerCache = [Environment]::GetEnvironmentVariable('npm_config_cache', 'Process')
$pushedLocation = $false
$repairExit = 1
try {
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
[Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $cacheDir, 'Process')
[Environment]::SetEnvironmentVariable('npm_config_cache', $cacheDir, 'Process')
Push-Location $projectRoot
$pushedLocation = $true
Write-Host 'codex-platform-repair: retrying managed npm install once with a fresh cache'
$repairOutput = & npm.cmd install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund 2>&1
$repairExit = $LASTEXITCODE
if ($null -ne $repairOutput) { $repairOutput | ForEach-Object { Write-Host $_ } }
} finally {
if ($pushedLocation) { Pop-Location }
[Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $oldUpperCache, 'Process')
[Environment]::SetEnvironmentVariable('npm_config_cache', $oldLowerCache, 'Process')
Remove-Item $cacheDir -Force -Recurse -ErrorAction SilentlyContinue
}
if ($repairExit -ne 0) {
Write-Warning "codex-platform-repair: npm install failed with exit code $repairExit"
return $false
}
Write-Host 'codex-platform-repair: managed npm install completed'
return $true
}`;
}
export function providerOnlyPluginId(modelId: string, fallbackPluginId: string): string {
return providerIdFromModelId(modelId) || fallbackPluginId;
}
+9 -1
View File
@@ -33,7 +33,10 @@ import { runWindowsBackgroundPowerShell, WindowsGuest } from "./guest-transports
import { startHostServer } from "./host-server.ts";
import { ensureVmRunning } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
import { windowsProviderOnlyPluginIsolationScript } from "./plugin-isolation.ts";
import {
windowsCodexPlatformPackageRepairFunction,
windowsProviderOnlyPluginIsolationScript,
} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
@@ -725,6 +728,7 @@ $PSNativeCommandUseErrorActionPreference = $false
${windowsPortableGitPathScript}
${windowsAgentTurnConfigPatchScript(this.auth.modelId)}
${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")}
${windowsCodexPlatformPackageRepairFunction()}
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
$agentOk = $false
for ($attempt = 1; $attempt -le 2; $attempt++) {
@@ -754,6 +758,10 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
$agentOk = $true
break
}
if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
}
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt failed or finished without OK response; retrying"
Start-Sleep -Seconds 3
+218 -54
View File
@@ -1,46 +1,189 @@
#!/usr/bin/env node
// Formats docs Markdown/MDX and repairs Mintlify accordion indentation.
import { execFileSync, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
const ROOT = path.resolve(import.meta.dirname, "..");
const CHECK = process.argv.includes("--check");
const OXFMT_BIN = path.join(ROOT, "node_modules", "oxfmt", "bin", "oxfmt");
const OXFMT_CONFIG = path.join(ROOT, ".oxfmtrc.jsonc");
const DOCS_FORMAT_MAX_BUFFER_BYTES = 1024 * 1024 * 16;
export const DOCS_FORMAT_MAX_COMMAND_LINE_BYTES = 24 * 1024;
const FAILURE_OUTPUT_TAIL_BYTES = 16 * 1024;
function docsFiles() {
const output = execFileSync("git", ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"], {
cwd: ROOT,
encoding: "utf8",
});
return output
.split("\n")
.filter(Boolean)
.filter((relativePath) => fs.existsSync(path.join(ROOT, relativePath)));
function outputText(value) {
if (typeof value === "string") {
return value;
}
if (Buffer.isBuffer(value)) {
return value.toString("utf8");
}
return "";
}
function runOxfmt(files) {
const result = spawnSync(
process.execPath,
[OXFMT_BIN, "--write", "--threads=1", "--config", OXFMT_CONFIG, ...files],
{
cwd: ROOT,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 16,
},
);
function outputTail(value) {
const text = outputText(value).trim();
if (!text) {
return "";
}
const bytes = Buffer.from(text, "utf8");
if (bytes.byteLength <= FAILURE_OUTPUT_TAIL_BYTES) {
return text;
}
return bytes.subarray(bytes.byteLength - FAILURE_OUTPUT_TAIL_BYTES).toString("utf8");
}
if (result.status !== 0) {
const stderr = result.stderr.trim();
throw new Error(`oxfmt failed${stderr ? `:\n${stderr}` : ""}`);
function commandFailureMessage(label, result, invocation) {
const details = [];
if (invocation) {
details.push(`command: ${invocation.command}`);
if (invocation.args.length > 0) {
const previewArgs = invocation.args.slice(0, 12).join(" ");
const suffix = invocation.args.length > 12 ? ` ... (${invocation.args.length} args)` : "";
details.push(`args: ${previewArgs}${suffix}`);
}
}
if (result.error?.message) {
details.push(result.error.message);
}
if (result.status !== null && result.status !== undefined && result.status !== 0) {
details.push(`exit status: ${result.status}`);
}
if (result.signal) {
details.push(`signal: ${result.signal}`);
}
const stderrTail = outputTail(result.stderr);
if (stderrTail) {
details.push(`stderr tail:\n${stderrTail}`);
}
const stdoutTail = outputTail(result.stdout);
if (stdoutTail) {
details.push(`stdout tail:\n${stdoutTail}`);
}
return `${label} failed${details.length > 0 ? `:\n${details.join("\n")}` : ""}`;
}
export function docsFiles(root = ROOT, deps = {}) {
const spawnSyncImpl = deps.spawnSync ?? spawnSync;
const result = spawnSyncImpl("git", ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"], {
cwd: root,
encoding: "utf8",
maxBuffer: DOCS_FORMAT_MAX_BUFFER_BYTES,
});
if (result.error || result.status !== 0) {
throw new Error(
commandFailureMessage("git ls-files", result, {
command: "git",
args: ["ls-files", "docs/**/*.md", "docs/**/*.mdx", "README.md"],
}),
);
}
return outputText(result.stdout)
.split("\n")
.filter(Boolean)
.filter((relativePath) => (deps.existsSync ?? fs.existsSync)(path.join(root, relativePath)));
}
function commandLineBytes(args) {
return args.reduce((total, arg) => total + Buffer.byteLength(arg, "utf8") + 3, 0);
}
export function chunkFilesForCommand(
files,
prefixArgs,
maxBytes = DOCS_FORMAT_MAX_COMMAND_LINE_BYTES,
) {
const chunks = [];
let chunk = [];
let chunkBytes = commandLineBytes(prefixArgs);
for (const file of files) {
const fileBytes = Buffer.byteLength(file, "utf8") + 3;
if (chunk.length > 0 && chunkBytes + fileBytes > maxBytes) {
chunks.push(chunk);
chunk = [];
chunkBytes = commandLineBytes(prefixArgs);
}
chunk.push(file);
chunkBytes += fileBytes;
}
if (chunk.length > 0) {
chunks.push(chunk);
}
return chunks;
}
export function resolveOxfmtInvocation(args, params = {}) {
const repoRoot = params.repoRoot ?? ROOT;
const platform = params.platform ?? process.platform;
const existsSync = params.existsSync ?? fs.existsSync;
const shimName = platform === "win32" ? "oxfmt.cmd" : "oxfmt";
const shimPath = path.join(repoRoot, "node_modules", ".bin", shimName);
if (existsSync(shimPath)) {
if (platform === "win32") {
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine(shimPath, args)],
shell: false,
windowsVerbatimArguments: true,
};
}
return {
command: shimPath,
args,
shell: false,
};
}
return {
command: params.nodeExecPath ?? process.execPath,
args: [path.join(repoRoot, "node_modules", "oxfmt", "bin", "oxfmt"), ...args],
shell: false,
};
}
export function runOxfmt(files, params = {}, deps = {}) {
if (files.length === 0) {
return;
}
const repoRoot = params.repoRoot ?? ROOT;
const spawnSyncImpl = deps.spawnSync ?? spawnSync;
const prefixArgs = ["--write", "--threads=1", "--config", path.join(repoRoot, ".oxfmtrc.jsonc")];
for (const chunk of chunkFilesForCommand(
files,
prefixArgs,
params.maxCommandLineBytes ?? DOCS_FORMAT_MAX_COMMAND_LINE_BYTES,
)) {
const invocation = resolveOxfmtInvocation([...prefixArgs, ...chunk], {
comSpec: params.comSpec,
existsSync: deps.existsSync,
nodeExecPath: params.nodeExecPath,
platform: params.platform,
repoRoot,
});
const result = spawnSyncImpl(invocation.command, invocation.args, {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: DOCS_FORMAT_MAX_BUFFER_BYTES,
shell: invocation.shell,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
if (result.error || result.status !== 0) {
throw new Error(commandFailureMessage("oxfmt", result, invocation));
}
}
}
function repairFiles(root, files) {
export function repairFiles(root, files) {
const changed = [];
for (const relativePath of files) {
const absolutePath = path.join(root, relativePath);
@@ -55,10 +198,10 @@ function repairFiles(root, files) {
return changed;
}
function copyDocsToTemp(files) {
function copyDocsToTemp(root, files) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-format-"));
for (const relativePath of files) {
const source = path.join(ROOT, relativePath);
const source = path.join(root, relativePath);
const target = path.join(tempRoot, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
@@ -66,39 +209,60 @@ function copyDocsToTemp(files) {
return tempRoot;
}
const changed = [];
const files = docsFiles();
export function formatDocs(params = {}, deps = {}) {
const root = params.root ?? ROOT;
const check = params.check ?? false;
const changed = [];
const files = docsFiles(root, deps);
if (CHECK) {
const tempRoot = copyDocsToTemp(files);
try {
runOxfmt(files.map((relativePath) => path.join(tempRoot, relativePath)));
repairFiles(tempRoot, files);
for (const relativePath of files) {
const raw = fs.readFileSync(path.join(ROOT, relativePath), "utf8");
const formatted = fs.readFileSync(path.join(tempRoot, relativePath), "utf8");
if (formatted !== raw) {
changed.push(relativePath);
if (check) {
const tempRoot = copyDocsToTemp(root, files);
try {
runOxfmt(
files.map((relativePath) => path.join(tempRoot, relativePath)),
{ ...params, repoRoot: root },
deps,
);
repairFiles(tempRoot, files);
for (const relativePath of files) {
const raw = fs.readFileSync(path.join(root, relativePath), "utf8");
const formatted = fs.readFileSync(path.join(tempRoot, relativePath), "utf8");
if (formatted !== raw) {
changed.push(relativePath);
}
}
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
} else {
runOxfmt(files, { ...params, repoRoot: root }, deps);
changed.push(...repairFiles(root, files));
}
} else {
runOxfmt(files);
changed.push(...repairFiles(ROOT, files));
return {
changed,
fileCount: files.length,
};
}
if (CHECK && changed.length > 0) {
console.error(`Format issues found in ${changed.length} docs file(s):`);
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
function main() {
const { changed, fileCount } = formatDocs({ check: CHECK, root: ROOT });
if (CHECK && changed.length > 0) {
console.error(`Format issues found in ${changed.length} docs file(s):`);
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
}
process.exit(1);
}
if (changed.length > 0) {
console.log(`Formatted ${changed.length} docs file(s).`);
} else {
console.log(`Docs formatting clean (${fileCount} files).`);
}
process.exit(1);
}
if (changed.length > 0) {
console.log(`Formatted ${changed.length} docs file(s).`);
} else {
console.log(`Docs formatting clean (${files.length} files).`);
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main();
}
+2 -1
View File
@@ -3,7 +3,7 @@
// Profiles peak RSS for built bundled plugin entrypoints and emits a JSON
// report suitable for extension memory budget review.
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -434,6 +434,7 @@ async function main() {
results,
};
mkdirSync(path.dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`[extension-memory] report: ${jsonPath}`);
+11 -2
View File
@@ -542,6 +542,15 @@ export function dockerPreflightContainerNames(raw) {
);
}
export function resolveDockerPreflightPlatform(arch = process.arch) {
return arch === "arm64" ? "linux/arm64" : "linux/amd64";
}
export function dockerPreflightSmokeCommand(arch = process.arch) {
const platform = resolveDockerPreflightPlatform(arch);
return `docker run --rm --platform ${shellQuote(platform)} alpine:3.20 true`;
}
export function runShellCommand({ command, env, label, logFile, timeoutMs, noOutputTimeoutMs }) {
return new Promise((resolve) => {
const pipeOutput = Boolean(logFile || noOutputTimeoutMs > 0);
@@ -873,7 +882,7 @@ async function runDockerPreflight(baseEnv, options) {
const startedAt = Date.now();
const run = await runShellCommand({
command: "docker run --rm alpine:3.20 true",
command: dockerPreflightSmokeCommand(),
env: baseEnv,
label: "docker-run-smoke",
timeoutMs: options.runTimeoutMs,
@@ -881,7 +890,7 @@ async function runDockerPreflight(baseEnv, options) {
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
if (run.status !== 0) {
throw new Error(
`Docker preflight failed: docker run alpine:3.20 true status=${run.status} elapsed=${elapsedSeconds}s`,
`Docker preflight failed: ${dockerPreflightSmokeCommand()} status=${run.status} elapsed=${elapsedSeconds}s`,
);
}
console.log(`==> Docker preflight run: ${elapsedSeconds}s`);
+1 -1
View File
@@ -22,7 +22,7 @@ ALLOWED_RESOURCES = {"scripts", "references", "assets"}
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
description: '[TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]'
---
# {skill_title}
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Regression tests for skill initialization.
"""
import shutil
import sys
import tempfile
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from unittest import TestCase, main
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import init_skill
class TestInitSkill(TestCase):
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp(prefix="test_init_skill_"))
def tearDown(self):
if self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
def test_generated_description_placeholder_is_yaml_string(self):
with redirect_stdout(StringIO()):
skill_dir = init_skill.init_skill("yaml-description-skill", self.temp_dir, [], False)
self.assertIsNotNone(skill_dir)
content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
frontmatter = content.split("---", 2)[1]
self.assertIn("description: '[TODO:", frontmatter)
try:
import yaml
except ImportError:
self.skipTest("PyYAML is not installed")
parsed = yaml.safe_load(frontmatter)
self.assertIsInstance(parsed["description"], str)
self.assertTrue(parsed["description"].startswith("[TODO: Complete"))
if __name__ == "__main__":
main()
+44 -42
View File
@@ -108,6 +108,31 @@ async function createHeartbeatAgentsWorkspace() {
return workspaceDir;
}
async function writeCompletedWorkspaceState(workspaceDir: string): Promise<void> {
await fs.writeFile(
path.join(workspaceDir, "openclaw-workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
}
async function writeLegacyCompletedWorkspaceState(workspaceDir: string): Promise<void> {
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
}
function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) {
// Heartbeat policy can remove HEARTBEAT.md for normal turns, but project rules
// must remain in the bootstrap set.
@@ -174,16 +199,7 @@ describe("resolveBootstrapFilesForRun", () => {
it("ignores stale workspace BOOTSTRAP.md once setup is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -193,9 +209,21 @@ describe("resolveBootstrapFilesForRun", () => {
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("keeps BOOTSTRAP.md when setup state cannot be read", async () => {
it("ignores stale workspace BOOTSTRAP.md when legacy setup state is completed", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), {
await writeLegacyCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.map((file) => file.name)).toContain("AGENTS.md");
expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md");
});
it("keeps BOOTSTRAP.md when current setup state cannot be read", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, "openclaw-workspace-state.json"), {
recursive: true,
});
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
@@ -209,16 +237,7 @@ describe("resolveBootstrapFilesForRun", () => {
it("does not let hooks re-add stale root BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook();
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -231,16 +250,8 @@ describe("resolveBootstrapFilesForRun", () => {
registerBootstrapFileHook();
const parentDir = await makeTempWorkspace("openclaw-bootstrap-home-");
const workspaceDir = path.join(parentDir, "workspace");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await fs.mkdir(workspaceDir, { recursive: true });
await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
@@ -263,17 +274,8 @@ describe("resolveBootstrapFilesForRun", () => {
it("keeps hook-added nested BOOTSTRAP.md after setup is completed", async () => {
registerBootstrapFileHook(path.join("packages", "core", "BOOTSTRAP.md"));
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
await fs.mkdir(path.join(workspaceDir, "packages", "core"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, ".openclaw", "workspace-state.json"),
`${JSON.stringify({
version: 1,
bootstrapSeededAt: "2026-05-16T00:00:00.000Z",
setupCompletedAt: "2026-05-16T00:00:01.000Z",
})}\n`,
"utf8",
);
await writeCompletedWorkspaceState(workspaceDir);
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8");
await fs.writeFile(
+4
View File
@@ -218,6 +218,10 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
label: "screen record",
detailKeys: ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"],
},
screen_snapshot: {
label: "screen snapshot",
detailKeys: ["node", "nodeId", "screenIndex", "maxWidth"],
},
},
},
cron: {
+52 -1
View File
@@ -16,8 +16,11 @@ import {
} from "../../cli/nodes-camera.js";
import {
parseScreenRecordPayload,
parseScreenSnapshotPayload,
screenRecordTempPath,
screenSnapshotTempPath,
writeScreenRecordToFile,
writeScreenSnapshotToFile,
} from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import type { ImageSanitizationLimits } from "../image-sanitization.js";
@@ -37,6 +40,7 @@ export const MEDIA_INVOKE_ACTIONS = {
"camera.clip": "camera_clip",
"photos.latest": "photos_latest",
"screen.record": "screen_record",
"screen.snapshot": "screen_snapshot",
// file-transfer commands: redirect to dedicated tools for better result
// formatting and media-store handling. The gateway still enforces the
// underlying node-invoke path policy for raw callers.
@@ -55,7 +59,12 @@ export const POLICY_REDIRECT_INVOKE_COMMANDS: ReadonlySet<string> = new Set([
"file.write",
]);
export type NodeMediaAction = "camera_snap" | "photos_latest" | "camera_clip" | "screen_record";
export type NodeMediaAction =
| "camera_snap"
| "photos_latest"
| "camera_clip"
| "screen_record"
| "screen_snapshot";
const MAX_RECORDING_DURATION_MS = 300_000;
type ExecuteNodeMediaActionParams = {
@@ -78,6 +87,8 @@ export async function executeNodeMediaAction(
return await executeCameraClip(input);
case "screen_record":
return await executeScreenRecord(input);
case "screen_snapshot":
return await executeScreenSnapshot(input);
}
throw new Error("Unsupported node media action");
}
@@ -389,6 +400,46 @@ async function executeScreenRecord({
};
}
async function executeScreenSnapshot({
params,
gatewayOpts,
}: ExecuteNodeMediaActionParams): Promise<AgentToolResult<unknown>> {
const node = requireString(params, "node");
const nodeId = await resolveNodeId(gatewayOpts, node);
const screenIndex = readNonNegativeIntegerParam(params, "screenIndex") ?? 0;
const maxWidth = readPositiveIntegerParam(params, "maxWidth");
const raw = await callGatewayTool<{ payload: unknown }>("node.invoke", gatewayOpts, {
nodeId,
command: "screen.snapshot",
params: { screenIndex, maxWidth },
idempotencyKey: crypto.randomUUID(),
});
const payload = parseScreenSnapshotPayload(raw?.payload);
const normalizedFormat = normalizeLowercaseStringOrEmpty(payload.format);
if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") {
throw new Error(`unsupported screen.snapshot format: ${payload.format}`);
}
const ext = normalizedFormat === "png" ? "png" : "jpg";
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenSnapshotTempPath({ ext });
const written = await writeScreenSnapshotToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
format: payload.format,
screenIndex: payload.screenIndex,
width: payload.width,
height: payload.height,
media: {
mediaUrl: written.path,
},
},
};
}
function requireString(params: Record<string, unknown>, key: string): string {
const raw = params[key];
if (typeof raw !== "string" || raw.trim().length === 0) {
+112
View File
@@ -38,6 +38,15 @@ const screenMocks = vi.hoisted(() => ({
})),
screenRecordTempPath: vi.fn(() => "/tmp/screen-record.mp4"),
writeScreenRecordToFile: vi.fn(async () => ({ path: "/tmp/screen-record.mp4" })),
parseScreenSnapshotPayload: vi.fn(() => ({
base64: "ZmFrZQ==",
format: "png",
screenIndex: 0,
width: 1920,
height: 1080,
})),
screenSnapshotTempPath: vi.fn(() => "/tmp/screen-snapshot.png"),
writeScreenSnapshotToFile: vi.fn(async () => ({ path: "/tmp/screen-snapshot.png" })),
}));
vi.mock("./gateway.js", () => ({
@@ -62,6 +71,9 @@ vi.mock("../../cli/nodes-screen.js", () => ({
parseScreenRecordPayload: screenMocks.parseScreenRecordPayload,
screenRecordTempPath: screenMocks.screenRecordTempPath,
writeScreenRecordToFile: screenMocks.writeScreenRecordToFile,
parseScreenSnapshotPayload: screenMocks.parseScreenSnapshotPayload,
screenSnapshotTempPath: screenMocks.screenSnapshotTempPath,
writeScreenSnapshotToFile: screenMocks.writeScreenSnapshotToFile,
}));
let createNodesTool: typeof import("./nodes-tool.js").createNodesTool;
@@ -123,6 +135,9 @@ describe("createNodesTool screen_record duration guardrails", () => {
nodeUtilsMocks.resolveNode.mockClear();
screenMocks.parseScreenRecordPayload.mockClear();
screenMocks.writeScreenRecordToFile.mockClear();
screenMocks.parseScreenSnapshotPayload.mockClear();
screenMocks.screenSnapshotTempPath.mockClear();
screenMocks.writeScreenSnapshotToFile.mockClear();
nodesCameraMocks.cameraTempPath.mockClear();
nodesCameraMocks.parseCameraSnapPayload.mockClear();
nodesCameraMocks.writeCameraPayloadToFile.mockClear();
@@ -258,6 +273,69 @@ describe("createNodesTool screen_record duration guardrails", () => {
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("invokes screen.snapshot with validated params and returns file details", async () => {
gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
const tool = createNodesTool();
const result = await tool.execute("call-snapshot", {
action: "screen_snapshot",
node: "macbook",
screenIndex: 1,
maxWidth: "1200",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledTimes(1);
const call = gatewayMocks.callGatewayTool.mock.calls[0] as
| [
string,
unknown,
{ command?: string; params?: { screenIndex?: unknown; maxWidth?: unknown } },
]
| undefined;
expect(call?.[0]).toBe("node.invoke");
expect(call?.[2].command).toBe("screen.snapshot");
expect(call?.[2].params).toEqual({ screenIndex: 1, maxWidth: 1200 });
expect(screenMocks.parseScreenSnapshotPayload).toHaveBeenCalledWith({ ok: true });
expect(screenMocks.screenSnapshotTempPath).toHaveBeenCalledWith({ ext: "png" });
expect(screenMocks.writeScreenSnapshotToFile).toHaveBeenCalledWith(
"/tmp/screen-snapshot.png",
"ZmFrZQ==",
);
expect(result).toEqual({
content: [{ type: "text", text: "FILE:/tmp/screen-snapshot.png" }],
details: {
path: "/tmp/screen-snapshot.png",
format: "png",
screenIndex: 0,
width: 1920,
height: 1080,
media: {
mediaUrl: "/tmp/screen-snapshot.png",
},
},
});
});
it("rejects unsupported screen.snapshot response formats before writing", async () => {
gatewayMocks.callGatewayTool.mockResolvedValue({ payload: { ok: true } });
screenMocks.parseScreenSnapshotPayload.mockReturnValueOnce({
base64: "ZmFrZQ==",
format: "webp",
screenIndex: 0,
width: 1920,
height: 1080,
});
const tool = createNodesTool();
await expect(
tool.execute("call-snapshot", {
action: "screen_snapshot",
node: "macbook",
}),
).rejects.toThrow("unsupported screen.snapshot format: webp");
expect(screenMocks.writeScreenSnapshotToFile).not.toHaveBeenCalled();
});
it("rejects the removed run action", async () => {
const tool = createNodesTool();
@@ -387,6 +465,8 @@ describe("createNodesTool screen_record duration guardrails", () => {
["photos_latest", { quality: -0.1 }, "quality must be between 0 and 1"],
["screen_record", { fps: 0 }, "fps must be greater than 0"],
["screen_record", { screenIndex: 1.5 }, "screenIndex must be a non-negative integer"],
["screen_snapshot", { maxWidth: 0 }, "maxWidth must be a positive integer"],
["screen_snapshot", { screenIndex: -1 }, "screenIndex must be a non-negative integer"],
])("rejects invalid %s numeric params %s", async (action, params, message) => {
const tool = createNodesTool();
@@ -561,6 +641,38 @@ describe("createNodesTool screen_record duration guardrails", () => {
);
});
it("blocks raw screen.snapshot invoke to prevent base64 context bloat", async () => {
const tool = createNodesTool();
await expect(
tool.execute("call-1", {
action: "invoke",
node: "macbook",
invokeCommand: "screen.snapshot",
}),
).rejects.toThrow('use action="screen_snapshot"');
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("preserves explicitly enabled raw screen.snapshot invoke", async () => {
gatewayMocks.callGatewayTool.mockResolvedValue({
payload: { format: "png", base64: "ZmFrZQ==" },
});
const tool = createNodesTool({ allowMediaInvokeCommands: true });
await tool.execute("call-1", {
action: "invoke",
node: "macbook",
invokeCommand: "screen.snapshot",
});
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{},
expect.objectContaining({ command: "screen.snapshot" }),
);
});
it("keeps invoke pairing guidance for scope upgrade rejections", async () => {
gatewayMocks.callGatewayTool.mockRejectedValueOnce(
new Error("scope upgrade pending approval (requestId: req-123)"),
+11 -1
View File
@@ -39,6 +39,7 @@ const NODES_TOOL_ACTIONS = [
"camera_clip",
"photos_latest",
"screen_record",
"screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
@@ -97,7 +98,7 @@ const NodesToolSchema = Type.Object({
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
// camera_snap / camera_clip
// camera_snap / camera_clip / photos_latest / screen_snapshot
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}),
@@ -271,6 +272,15 @@ export function createNodesTool(options?: {
imageSanitization,
});
}
case "screen_snapshot": {
return await executeNodeMediaAction({
action,
params,
gatewayOpts,
modelHasVision: options?.modelHasVision,
imageSanitization,
});
}
case "location_get": {
return await executeNodeCommandAction({
action,
+46 -10
View File
@@ -66,7 +66,8 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
const WORKSPACE_STATE_PATH_SEGMENTS = ["openclaw-workspace-state.json"] as const;
const LEGACY_WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
async function readWorkspaceState(dir: string): Promise<{
version: number;
@@ -81,6 +82,14 @@ async function readWorkspaceState(dir: string): Promise<{
};
}
async function writeLegacyWorkspaceState(dir: string, state: unknown): Promise<void> {
await fs.mkdir(path.join(dir, LEGACY_WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
await fs.writeFile(
path.join(dir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
`${JSON.stringify(state)}\n`,
);
}
async function expectBootstrapSeeded(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
const state = await readWorkspaceState(dir);
@@ -128,9 +137,37 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
await expectPathMissing(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS));
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("does not overwrite a foreign root workspace-state.json file", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const foreignStatePath = path.join(tempDir, "workspace-state.json");
const foreignState = "not openclaw state\n";
await fs.writeFile(foreignStatePath, foreignState);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
expect(await fs.readFile(foreignStatePath, "utf-8")).toBe(foreignState);
await expectBootstrapSeeded(tempDir);
});
it("ignores unreadable legacy nested state while writing current setup state", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS), {
recursive: true,
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
const legacyStateStat = await fs.stat(
path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS),
);
expect(legacyStateStat.isDirectory()).toBe(true);
});
it("refuses to re-seed a recently attested workspace after the directory disappears", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
@@ -217,7 +254,7 @@ describe("ensureAgentWorkspace", () => {
const state = await fs.readFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
await fs.rm(tempDir, { recursive: true, force: true });
await fs.mkdir(path.join(tempDir, WORKSPACE_STATE_PATH_SEGMENTS[0]), { recursive: true });
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(path.join(tempDir, DEFAULT_AGENTS_FILENAME), generatedAgents);
await fs.writeFile(path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS), state);
@@ -527,19 +564,18 @@ describe("ensureAgentWorkspace", () => {
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
JSON.stringify({
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
}),
);
await writeLegacyWorkspaceState(tempDir, {
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
});
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
await expect(
fs.access(path.join(tempDir, ...LEGACY_WORKSPACE_STATE_PATH_SEGMENTS)),
).resolves.toBeUndefined();
const persisted = await fs.readFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
"utf-8",
+70 -31
View File
@@ -36,8 +36,9 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;
const WORKSPACE_STATE_DIRNAME = ".openclaw";
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
const LEGACY_WORKSPACE_STATE_DIRNAME = ".openclaw";
const LEGACY_WORKSPACE_STATE_FILENAME = "workspace-state.json";
const WORKSPACE_STATE_FILENAME = "openclaw-workspace-state.json";
const WORKSPACE_STATE_VERSION = 1;
const WORKSPACE_ATTESTATION_SUFFIX = ".attested";
const WORKSPACE_ATTESTATION_DIRNAME = "workspace-attestations";
@@ -305,7 +306,11 @@ async function hasSkipBootstrapWorkspaceContentEvidence(dir: string): Promise<bo
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === ".DS_Store" || entry.name === WORKSPACE_STATE_DIRNAME) {
if (
entry.name === ".DS_Store" ||
entry.name === LEGACY_WORKSPACE_STATE_DIRNAME ||
entry.name === WORKSPACE_STATE_FILENAME
) {
continue;
}
if (entry.name === "skills" && entry.isDirectory()) {
@@ -455,7 +460,11 @@ async function reconcileWorkspaceBootstrapCompletionState(params: {
}
function resolveWorkspaceStatePath(dir: string): string {
return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME);
return path.join(dir, WORKSPACE_STATE_FILENAME);
}
function resolveLegacyWorkspaceStatePath(dir: string): string {
return path.join(dir, LEGACY_WORKSPACE_STATE_DIRNAME, LEGACY_WORKSPACE_STATE_FILENAME);
}
export function resolveWorkspaceAttestationPath(dir: string): string {
@@ -670,37 +679,68 @@ function parseWorkspaceSetupState(raw: string): WorkspaceSetupState | null {
}
}
async function readWorkspaceSetupState(
statePath: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
function hasWorkspaceSetupStateMarker(state: WorkspaceSetupState): boolean {
return Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
}
function needsWorkspaceSetupStateRewrite(raw: string, state: WorkspaceSetupState): boolean {
return (
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
Boolean(state.setupCompletedAt)
);
}
async function readWorkspaceSetupStateFile(statePath: string): Promise<{
raw: string;
state: WorkspaceSetupState;
} | null> {
try {
const raw = await fs.readFile(statePath, "utf-8");
const parsed = parseWorkspaceSetupState(raw);
if (
opts?.persistLegacyMigration &&
parsed &&
raw.includes('"onboardingCompletedAt"') &&
!raw.includes('"setupCompletedAt"') &&
parsed.setupCompletedAt
) {
await writeWorkspaceSetupState(statePath, parsed);
}
return parsed ?? { version: WORKSPACE_STATE_VERSION };
return parsed ? { raw, state: parsed } : null;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") {
throw err;
}
return {
version: WORKSPACE_STATE_VERSION,
};
return null;
}
}
async function readWorkspaceSetupStateForDir(dir: string): Promise<WorkspaceSetupState> {
const statePath = resolveWorkspaceStatePath(resolveUserPath(dir));
return await readWorkspaceSetupState(statePath);
async function readWorkspaceSetupStateForDir(
dir: string,
opts?: { persistLegacyMigration?: boolean },
): Promise<WorkspaceSetupState> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const canonical = await readWorkspaceSetupStateFile(statePath);
if (canonical) {
if (
opts?.persistLegacyMigration &&
needsWorkspaceSetupStateRewrite(canonical.raw, canonical.state)
) {
await writeWorkspaceSetupState(statePath, canonical.state);
}
return canonical.state;
}
const legacyStatePath = resolveLegacyWorkspaceStatePath(resolvedDir);
let legacy: Awaited<ReturnType<typeof readWorkspaceSetupStateFile>>;
try {
legacy = await readWorkspaceSetupStateFile(legacyStatePath);
} catch {
// Legacy state lived under a dot directory that some workspaces reject.
// Treat inaccessible legacy metadata as absent so current setup can proceed.
legacy = null;
}
if (!legacy) {
return { version: WORKSPACE_STATE_VERSION };
}
if (opts?.persistLegacyMigration && hasWorkspaceSetupStateMarker(legacy.state)) {
await writeWorkspaceSetupState(statePath, legacy.state);
}
return legacy.state;
}
export async function isWorkspaceSetupCompleted(dir: string): Promise<boolean> {
@@ -712,8 +752,7 @@ export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const state = await readWorkspaceSetupState(statePath);
const state = await readWorkspaceSetupStateForDir(resolvedDir);
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
@@ -735,7 +774,7 @@ export async function reconcileWorkspaceBootstrapCompletion(
const resolvedDir = resolveUserPath(dir);
const statePath = resolveWorkspaceStatePath(resolvedDir);
const bootstrapPath = path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME);
const state = await readWorkspaceSetupState(statePath, {
const state = await readWorkspaceSetupStateForDir(resolvedDir, {
persistLegacyMigration: true,
});
return await reconcileWorkspaceBootstrapCompletionState({
@@ -753,7 +792,7 @@ async function writeWorkspaceSetupState(
await replaceFileAtomic({
filePath: statePath,
content: `${JSON.stringify(state, null, 2)}\n`,
tempPrefix: ".workspace-state",
tempPrefix: WORKSPACE_STATE_FILENAME,
});
}
@@ -885,10 +924,10 @@ export async function ensureAgentWorkspace(params?: {
if (recentAttestationPath && !isBrandNewWorkspace) {
const bootstrapExists = await pathExists(bootstrapPath);
const state = await readWorkspaceSetupState(statePath, {
const state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
const hasSetupState = Boolean(state.bootstrapSeededAt || state.setupCompletedAt);
const hasSetupState = hasWorkspaceSetupStateMarker(state);
const hasCustomizedRequiredBootstrap = await workspaceRequiredBootstrapLooksCustomized(dir, {
attestationPath: recentAttestationPath,
});
@@ -933,7 +972,7 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
}
let state = await readWorkspaceSetupState(statePath, {
let state = await readWorkspaceSetupStateForDir(dir, {
persistLegacyMigration: true,
});
let stateDirty = false;
@@ -15,6 +15,11 @@ import {
type FollowupRun,
type QueueSettings,
} from "./queue.js";
import {
REPLY_OPERATION_RUN_STATE,
type ReplyOperationRunState,
type ReplyOptionsWithOperationRunState,
} from "./reply-operation-run-state.js";
import { createReplyOperation, testing as replyRunTesting } from "./reply-run-registry.js";
import { consumeReplyUsageState } from "./reply-usage-state.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -152,7 +157,7 @@ beforeEach(() => {
});
function createMinimalRun(params?: {
opts?: GetReplyOptions;
opts?: GetReplyOptions & ReplyOptionsWithOperationRunState;
resolvedVerboseLevel?: "off" | "on";
sessionStore?: Record<string, SessionEntry>;
sessionEntry?: SessionEntry;
@@ -245,13 +250,14 @@ function createMinimalRun(params?: {
describe("runReplyAgent heartbeat followup guard", () => {
it("drops heartbeat runs when reply-lane admission finds an active owner", async () => {
const runState: ReplyOperationRunState = {};
const active = createReplyOperation({
sessionKey: "main",
sessionId: "active-session",
resetTriggered: false,
});
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true },
opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
isActive: false,
shouldFollowup: false,
});
@@ -261,9 +267,21 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(result).toBeUndefined();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
expect(runState.admission).toEqual({ status: "skipped", reason: "active-run" });
active.complete();
});
it("records the operation owned by an admitted heartbeat run", async () => {
const runState: ReplyOperationRunState = {};
const { run } = createMinimalRun({
opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
});
await run();
expect(runState.admission).toEqual({ status: "owned" });
});
it("runs visible turns with the session id returned by admission", async () => {
const active = createReplyOperation({
sessionKey: "main",
@@ -315,8 +333,12 @@ describe("runReplyAgent heartbeat followup guard", () => {
it("drops runs when reply-lane admission sees an already-aborted caller", async () => {
const abortController = new AbortController();
abortController.abort();
const runState: ReplyOperationRunState = {};
const { run, typing } = createMinimalRun({
opts: { abortSignal: abortController.signal },
opts: {
abortSignal: abortController.signal,
[REPLY_OPERATION_RUN_STATE]: runState,
},
isActive: false,
shouldFollowup: false,
});
@@ -326,11 +348,13 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(result).toBeUndefined();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
expect(runState.admission).toEqual({ status: "skipped", reason: "aborted" });
});
it("drops heartbeat runs when another run is active", async () => {
const runState: ReplyOperationRunState = {};
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true },
opts: { isHeartbeat: true, [REPLY_OPERATION_RUN_STATE]: runState },
isActive: true,
shouldFollowup: true,
resolvedQueueMode: "collect",
@@ -342,6 +366,7 @@ describe("runReplyAgent heartbeat followup guard", () => {
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
expect(runState.admission).toEqual({ status: "skipped", reason: "active-run" });
});
it("drops heartbeat runs before steering active streams", async () => {
+14
View File
@@ -118,6 +118,7 @@ import {
type QueueSettings,
} from "./queue.js";
import { createReplyMediaContext } from "./reply-media-paths.js";
import { resolveReplyOperationRunState } from "./reply-operation-run-state.js";
import {
replyRunRegistry,
runAfterReplyOperationClear,
@@ -1202,6 +1203,7 @@ export async function runReplyAgent(params: {
const activeRunQueueMode = effectiveResetTriggered ? "interrupt" : resolvedQueue.mode;
const isHeartbeat = opts?.isHeartbeat === true;
const replyOperationRunState = resolveReplyOperationRunState(opts);
const traceAttributes = {
provider: followupRun.run.provider,
hasSessionKey: Boolean(sessionKey ?? followupRun.run.sessionKey),
@@ -1295,6 +1297,9 @@ export async function runReplyAgent(params: {
});
if (activeRunQueueAction === "drop") {
if (replyOperationRunState) {
replyOperationRunState.admission = { status: "skipped", reason: "active-run" };
}
typing.cleanup();
return undefined;
}
@@ -1405,6 +1410,9 @@ export async function runReplyAgent(params: {
let replyOperation: ReplyOperation;
if (providedReplyOperation) {
replyOperation = providedReplyOperation;
if (replyOperationRunState) {
replyOperationRunState.admission = { status: "owned" };
}
} else {
const replyTurnKind = resolveReplyTurnKind(opts);
const admission = await admitReplyTurn({
@@ -1415,6 +1423,12 @@ export async function runReplyAgent(params: {
routeThreadId: replyRouteThreadId,
upstreamAbortSignal: opts?.abortSignal,
});
if (replyOperationRunState) {
replyOperationRunState.admission =
admission.status === "owned"
? { status: "owned" }
: { status: "skipped", reason: admission.reason };
}
if (admission.status === "skipped") {
typing.cleanup();
if (admission.reason !== "active-run" || replyTurnKind !== "visible") {
+11 -3
View File
@@ -248,7 +248,9 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
const json = JSON.stringify(overrides, null, 2);
const schema = loadGatewayRuntimeConfigSchema();
const redactedOverrides = redactConfigObject(overrides, schema.uiHints);
const json = JSON.stringify(redactedOverrides, null, 2);
return {
shouldContinue: false,
reply: {
@@ -292,8 +294,14 @@ export const handleDebugCommand: CommandHandler = async (params, allowTextComman
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
};
}
const valueLabel =
typeof debugCommand.value === "string"
const parsedOverridePath = parseConfigPath(debugCommand.path);
const valueLabel = parsedOverridePath.path
? formatConfigSetValueLabel({
path: parsedOverridePath.path,
value: debugCommand.value,
uiHints: loadGatewayRuntimeConfigSchema().uiHints,
})
: typeof debugCommand.value === "string"
? `"${debugCommand.value}"`
: JSON.stringify(debugCommand.value);
return {
@@ -182,6 +182,20 @@ vi.mock("./debug-commands.js", () => ({
if (!raw.startsWith("/debug")) {
return null;
}
const parts = raw.trim().split(/\s+/);
const action = parts[1];
if (action === "set") {
const assignment = raw.slice(raw.indexOf(" set ") + 5).trim();
const equalsIndex = assignment.indexOf("=");
return {
action: "set",
path: assignment.slice(0, equalsIndex),
value: JSON.parse(assignment.slice(equalsIndex + 1)),
};
}
if (action === "unset") {
return { action: "unset", path: parts.slice(2).join(" ") };
}
return { action: "show" };
}),
}));
@@ -527,6 +541,56 @@ describe("command gating", () => {
expect(output).not.toContain("OPENCLAW_CONFIG_SET_CANARY_TOKEN_65623");
});
it("redacts secret-shaped fields from /debug show replies", async () => {
getConfigOverridesMock.mockReturnValueOnce({
gateway: {
auth: {
token: "OPENCLAW_DEBUG_SHOW_CANARY_TOKEN_65623",
},
},
channels: {
telegram: {
botToken: "OPENCLAW_DEBUG_SHOW_CANARY_BOT_TOKEN_65623",
},
},
messages: {
ackReaction: ":)",
},
});
const params = buildParams("/debug show", {
commands: { debug: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
const result = await handleDebugCommand(params, true);
const output = result?.reply?.text ?? "";
expect(output).toContain("Debug overrides (memory-only)");
expect(output).toContain(REDACTED_SENTINEL);
expect(output).toContain("ackReaction");
expect(output).not.toContain("OPENCLAW_DEBUG_SHOW_CANARY_TOKEN_65623");
expect(output).not.toContain("OPENCLAW_DEBUG_SHOW_CANARY_BOT_TOKEN_65623");
});
it("redacts secret-shaped values from /debug set acknowledgements", async () => {
const params = buildParams(
'/debug set gateway.auth.token="OPENCLAW_DEBUG_SET_CANARY_TOKEN_65623"',
{
commands: { debug: true, text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig,
);
params.command.senderIsOwner = true;
const result = await handleDebugCommand(params, true);
const output = result?.reply?.text ?? "";
expect(output).toContain("Debug override set: gateway.auth.token=");
expect(output).toContain(REDACTED_SENTINEL);
expect(output).not.toContain("OPENCLAW_DEBUG_SET_CANARY_TOKEN_65623");
});
it("returns explicit unauthorized replies for native privileged commands", async () => {
const configParams = buildParams("/config show", {
commands: { config: true, text: true },
+27
View File
@@ -120,6 +120,33 @@ describe("group runtime loading", () => {
expect(disallowed).not.toContain("Never say that you are staying quiet");
});
it("binds an explicitly mentioned channel handle to the current assistant identity", () => {
const context = groups.buildGroupChatContext({
sessionCtx: {
ChatType: "group",
Provider: "telegram",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
},
silentToken: "NO_REPLY",
silentReplyPolicy: "allow",
});
expect(context).toContain("explicitly mentions your channel identity @SirPinchALotBot");
expect(context).toContain("Treat that mention as addressed to you");
const notExplicit = groups.buildGroupChatContext({
sessionCtx: {
ChatType: "group",
Provider: "telegram",
BotUsername: "kesslerAIBot",
},
silentToken: "NO_REPLY",
silentReplyPolicy: "allow",
});
expect(notExplicit).not.toContain("channel identity @kesslerAIBot");
});
it("marks non-visible assistant replies silent for groups with silence allowed", () => {
expect(
groups.resolveGroupSilentReplyBehavior({
+6
View File
@@ -231,9 +231,15 @@ export function buildGroupChatContext(params: {
const providerLabel = resolveProviderLabel(params.sessionCtx.Provider);
const provider = normalizeOptionalLowercaseString(params.sessionCtx.Provider);
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
const botUsername = normalizeOptionalString(params.sessionCtx.BotUsername);
const lines: string[] = [];
lines.push(`You are in a ${providerLabel} group chat.`);
if (params.sessionCtx.ExplicitlyMentionedBot === true && botUsername) {
lines.push(
`The incoming message explicitly mentions your channel identity @${botUsername}. Treat that mention as addressed to you, even if your persona name differs.`,
);
}
if (messageToolOnly) {
lines.push(
"Normal final replies are private and are not automatically sent to this group chat. To post visible output here, use the message tool with action=send; the target defaults to this group chat.",
@@ -0,0 +1,21 @@
export type ReplyOperationAdmissionSnapshot =
| { status: "owned" }
| { status: "skipped"; reason: "active-run" | "aborted" };
export type ReplyOperationRunState = {
admission?: ReplyOperationAdmissionSnapshot;
};
// Carries this invocation's admission decision through reply option spreads so
// heartbeat cleanup never infers it from whichever operation is active later.
export const REPLY_OPERATION_RUN_STATE = Symbol("openclaw.replyOperationRunState");
export type ReplyOptionsWithOperationRunState = {
[REPLY_OPERATION_RUN_STATE]?: ReplyOperationRunState;
};
export function resolveReplyOperationRunState(
options: object | undefined,
): ReplyOperationRunState | undefined {
return (options as ReplyOptionsWithOperationRunState | undefined)?.[REPLY_OPERATION_RUN_STATE];
}
@@ -86,6 +86,10 @@ describe("buildChannelInboundEventContext", () => {
mentions: {
canDetectMention: true,
wasMentioned: true,
explicitlyMentionedBot: true,
mentionSource: "explicit_bot",
mentionedUserIds: ["bot-1"],
implicitMentionKinds: ["reply_to_bot"],
},
},
commandTurn: {
@@ -161,6 +165,10 @@ describe("buildChannelInboundEventContext", () => {
Provider: "test-provider",
Surface: "test-surface",
WasMentioned: true,
ExplicitlyMentionedBot: true,
MentionedUserIds: ["bot-1"],
ImplicitMentionKinds: ["reply_to_bot"],
MentionSource: "explicit_bot",
CommandAuthorized: true,
CommandSource: "text",
CommandTurn: {
+5
View File
@@ -503,6 +503,11 @@ export function buildChannelInboundEventContext(
Provider: params.provider ?? params.channel,
Surface: params.surface ?? params.provider ?? params.channel,
WasMentioned: params.access?.mentions?.wasMentioned,
ExplicitlyMentionedBot: params.access?.mentions?.explicitlyMentionedBot,
MentionedUserIds: params.access?.mentions?.mentionedUserIds,
MentionedSubteamIds: params.access?.mentions?.mentionedSubteamIds,
ImplicitMentionKinds: params.access?.mentions?.implicitMentionKinds,
MentionSource: params.access?.mentions?.mentionSource,
CommandAuthorized: resolveAccessFactsCommandAuthorized(params.access) === true,
CommandTurn: commandTurn,
MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId,
+9 -1
View File
@@ -8,7 +8,11 @@ import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/his
import type { DispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.types.js";
import type { ReplyDispatcherWithTypingOptions } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js";
import type { FinalizedMsgContext, MsgContext } from "../../auto-reply/templating.js";
import type {
FinalizedMsgContext,
MentionSource,
MsgContext,
} from "../../auto-reply/templating.js";
import type { GroupKeyResolution } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type {
@@ -179,6 +183,10 @@ export type AccessFacts = {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
explicitlyMentionedBot?: boolean;
mentionedUserIds?: string[];
mentionedSubteamIds?: string[];
mentionSource?: MentionSource;
implicitMentionKinds?: Array<
"reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native"
>;
+52 -2
View File
@@ -29,8 +29,11 @@ let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCamera
let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile;
let writeUrlToFile: typeof import("./nodes-camera.js").writeUrlToFile;
let parseScreenRecordPayload: typeof import("./nodes-screen.js").parseScreenRecordPayload;
let parseScreenSnapshotPayload: typeof import("./nodes-screen.js").parseScreenSnapshotPayload;
let screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath;
let screenSnapshotTempPath: typeof import("./nodes-screen.js").screenSnapshotTempPath;
let writeScreenRecordToFile: typeof import("./nodes-screen.js").writeScreenRecordToFile;
let writeScreenSnapshotToFile: typeof import("./nodes-screen.js").writeScreenSnapshotToFile;
async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
return await withTempDir("openclaw-test-", run);
@@ -56,8 +59,14 @@ describe("nodes camera helpers", () => {
writeBase64ToFile,
writeUrlToFile,
} = await import("./nodes-camera.js"));
({ parseScreenRecordPayload, screenRecordTempPath, writeScreenRecordToFile } =
await import("./nodes-screen.js"));
({
parseScreenRecordPayload,
parseScreenSnapshotPayload,
screenRecordTempPath,
screenSnapshotTempPath,
writeScreenRecordToFile,
writeScreenSnapshotToFile,
} = await import("./nodes-screen.js"));
});
beforeEach(() => {
@@ -130,6 +139,13 @@ describe("nodes camera helpers", () => {
id: "id1",
}),
).toThrow(/invalid media format/i);
expect(() =>
screenSnapshotTempPath({
ext: "png/../../escaped",
tmpDir: "/tmp",
id: "id1",
}),
).toThrow(/invalid media format/i);
});
it("writes camera clip payload to temp path", async () => {
@@ -203,6 +219,10 @@ describe("nodes camera helpers", () => {
/exceeds max/i,
);
await expectPathMissing(out);
await expect(writeScreenSnapshotToFile(out, "aGk=", { maxBytes: 1 })).rejects.toThrow(
/exceeds max/i,
);
await expectPathMissing(out);
});
});
@@ -335,4 +355,34 @@ describe("nodes screen helpers", () => {
});
expect(p).toBe(path.join("/tmp", "openclaw-screen-record-id1.mp4"));
});
it("parses screen.snapshot payload", () => {
expect(
parseScreenSnapshotPayload({
format: "png",
base64: "Zm9v",
screenIndex: 1,
width: 1200,
height: 800,
}),
).toEqual({
format: "png",
base64: "Zm9v",
screenIndex: 1,
width: 1200,
height: 800,
});
});
it("rejects invalid screen.snapshot payload", () => {
expect(() => parseScreenSnapshotPayload({ format: "png" })).toThrow(
/invalid screen\.snapshot payload/i,
);
});
it("builds screen snapshot temp path", () => {
expect(screenSnapshotTempPath({ tmpDir: "/tmp", id: "id1" })).toBe(
path.join("/tmp", "openclaw-screen-snapshot-id1.png"),
);
});
});
+41
View File
@@ -45,3 +45,44 @@ export async function writeScreenRecordToFile(
) {
return writeBase64ToFile(filePath, base64, opts);
}
/** Validated payload returned by `nodes screen snapshot` RPC calls. */
export type ScreenSnapshotPayload = {
format: string;
base64: string;
screenIndex?: number;
width?: number;
height?: number;
};
/** Validate and normalize an unknown screen-snapshot payload. */
export function parseScreenSnapshotPayload(value: unknown): ScreenSnapshotPayload {
const obj = asRecord(value);
const format = asString(obj.format);
const base64 = asString(obj.base64);
if (!format || !base64) {
throw new Error("invalid screen.snapshot payload");
}
return {
format,
base64,
screenIndex: typeof obj.screenIndex === "number" ? obj.screenIndex : undefined,
width: typeof obj.width === "number" ? obj.width : undefined,
height: typeof obj.height === "number" ? obj.height : undefined,
};
}
/** Build the temp output path for a screen snapshot artifact. */
export function screenSnapshotTempPath(opts: { ext?: string; tmpDir?: string; id?: string }) {
const { tmpDir, id, ext } = resolveTempPathParts({ ...opts, ext: opts.ext ?? ".png" });
return path.join(tmpDir, `openclaw-screen-snapshot-${id}${ext}`);
}
/** Decode and write a screen snapshot payload to disk. */
export async function writeScreenSnapshotToFile(
filePath: string,
base64: string,
opts?: { maxBytes?: number },
) {
return writeBase64ToFile(filePath, base64, opts);
}
@@ -187,6 +187,39 @@ describe("normalizeCompatibilityConfigValues", () => {
);
});
it("removes null workspace values from agents.list entries", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
list: [
{ id: "main", workspace: null as unknown as string },
{ id: "beta", workspace: "/beta" },
{ id: "gamma" },
],
},
});
expect(res.config.agents?.list).toEqual([
{ id: "main" },
{ id: "beta", workspace: "/beta" },
{ id: "gamma" },
]);
expect(res.changes).toContain("Removed null workspace value from agents.list entry.");
});
it("does not alter agents.list when no workspace is null", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
list: [{ id: "main", workspace: "/main" }, { id: "beta" }],
},
});
expect(res.config.agents?.list).toEqual([
{ id: "main", workspace: "/main" },
{ id: "beta" },
]);
expect(res.changes.some((change) => change.includes("workspace"))).toBe(false);
});
it("removes bindings for missing configured agents", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
@@ -10,6 +10,40 @@ import {
normalizeLegacyOpenAICodexModelsAddMetadata,
} from "./legacy-config-core-normalizers.js";
function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
const agents = cfg.agents?.list;
if (!Array.isArray(agents)) {
return cfg;
}
let repaired = 0;
const nextAgents = agents.map((agent) => {
if (agent && typeof agent === "object" && (agent as Record<string, unknown>).workspace === null) {
repaired += 1;
const { workspace: _workspace, ...rest } = agent as Record<string, unknown>;
return rest;
}
return agent;
});
if (repaired === 0) {
return cfg;
}
changes.push(
`Removed null workspace value${repaired === 1 ? "" : "s"} from agents.list entr${
repaired === 1 ? "y" : "ies"
}.`,
);
return {
...cfg,
agents: {
...cfg.agents,
list: nextAgents as typeof agents,
},
};
}
function pruneBindingsForMissingAgents(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
const agents = cfg.agents?.list;
const bindings = cfg.bindings;
@@ -71,6 +105,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
next = normalizeLegacyCommandsConfig(next, changes);
next = normalizeLegacyOpenAICodexModelsAddMetadata(next, changes);
next = repairNullAgentWorkspaces(next, changes);
next = pruneBindingsForMissingAgents(next, changes);
return { config: next, changes };
+44 -16
View File
@@ -1,5 +1,5 @@
// Onboard skills tests cover skill setup prompts, package manager config, and skip behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -143,16 +143,30 @@ const runtime: RuntimeEnv = {
}) as RuntimeEnv["exit"],
};
const supportsHomebrewPrompt = process.platform === "darwin" || process.platform === "linux";
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", {
configurable: true,
value: platform,
});
try {
return await fn();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
}
describe("setupSkills", () => {
afterEach(() => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isContainerEnvironment.mockReset();
mocks.resolveBrewExecutable.mockReset();
});
it("hides brew-only installs in Linux containers when brew is missing", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
try {
await withPlatform("linux", async () => {
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
@@ -173,15 +187,11 @@ describe("setupSkills", () => {
expect(
notes.find((n) => n.message.includes("No missing skill dependencies to install")),
).toBeUndefined();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
});
});
it("keeps brew-only installs visible when Linuxbrew is resolved off PATH", async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
try {
await withPlatform("linux", async () => {
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
@@ -202,13 +212,11 @@ describe("setupSkills", () => {
);
expect(notes.find((n) => n.title === "Container skill installs")).toBeUndefined();
expect(notes.find((n) => n.title === "Homebrew recommended")).toBeUndefined();
} finally {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
});
});
it("does not recommend Homebrew when user skips installing brew-backed deps", async () => {
if (process.platform === "win32") {
if (!supportsHomebrewPrompt) {
return;
}
@@ -247,7 +255,7 @@ describe("setupSkills", () => {
});
it("recommends Homebrew when user selects a brew-backed install and brew is missing", async () => {
if (process.platform === "win32") {
if (!supportsHomebrewPrompt) {
return;
}
@@ -279,4 +287,24 @@ describe("setupSkills", () => {
expect(emptyStateNote?.message).toContain("openclaw skills list --verbose");
expect(emptyStateNote?.message).toContain("openclaw skills check");
});
it("does not recommend Homebrew on FreeBSD", async () => {
await withPlatform("freebsd", async () => {
mockMissingBrewStatus([
createBundledSkill({
name: "video-frames",
description: "ffmpeg",
bins: ["ffmpeg"],
installLabel: "Install ffmpeg (brew)",
}),
]);
const { prompter, notes } = createPrompter({ multiselect: ["video-frames"] });
await setupSkills({} as OpenClawConfig, "/tmp/ws", runtime, prompter);
const brewNote = notes.find((n) => n.title === "Homebrew recommended");
expect(brewNote).toBeUndefined();
expect(mocks.detectBinary).not.toHaveBeenCalledWith("brew");
});
});
});
+7 -1
View File
@@ -16,6 +16,12 @@ import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary, resolveNodeManagerOptions } from "./onboard-helpers.js";
const HOMEBREW_PROMPT_PLATFORMS = new Set(["darwin", "linux"]);
function supportsHomebrewPrompt(platform: NodeJS.Platform): boolean {
return HOMEBREW_PROMPT_PLATFORMS.has(platform);
}
function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message.replace(/^Install failed(?:\s*\([^)]*\))?\s*:?\s*/i, "").trim();
if (!cleaned) {
@@ -145,7 +151,7 @@ export async function setupSkills(
.filter((item): item is (typeof installable)[number] => Boolean(item));
const needsBrewPrompt =
process.platform !== "win32" &&
supportsHomebrewPrompt(process.platform) &&
selectedSkills.some((skill) => skill.install.some((option) => option.kind === "brew")) &&
!(await detectBrewOnce());
@@ -2,10 +2,15 @@
import { describe, expect, it } from "vitest";
import { CommandLaneTaskTimeoutError } from "../../process/command-queue.js";
import {
makeIsolatedAgentTurnJob,
makeIsolatedAgentTurnParams,
setupRunCronIsolatedAgentTurnSuite,
} from "./run.suite-helpers.js";
import { loadRunCronIsolatedAgentTurn, runWithModelFallbackMock } from "./run.test-harness.js";
import {
cleanupDirectCronSessionMock,
loadRunCronIsolatedAgentTurn,
runWithModelFallbackMock,
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
@@ -54,6 +59,36 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => {
expect(result.outputText).toBe("cron isolated run failed: retry limit exceeded");
});
it("marks an aborted embedded agent run without a run-level error as a cron error", async () => {
runWithModelFallbackMock.mockResolvedValueOnce({
result: {
payloads: [],
meta: {
aborted: true,
agentMeta: { usage: { input: 0, output: 0 } },
},
},
provider: "openai",
model: "gpt-5.4",
attempts: [],
});
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({ deleteAfterRun: true }),
}),
);
expect(result.status).toBe("error");
expect(result.error).toBe("cron isolated agent run aborted");
expect(cleanupDirectCronSessionMock).toHaveBeenCalledWith({
job: expect.objectContaining({ deleteAfterRun: true }),
agentSessionKey: "agent:default:cron:test",
sessionId: "test-session-id",
retireReason: "cron-delete-after-run-aborted",
});
});
it("surfaces cron timeout result when the cron-nested lane watchdog fires", async () => {
runWithModelFallbackMock.mockRejectedValueOnce(
new CommandLaneTaskTimeoutError("cron-nested", 330_000),
+20
View File
@@ -1078,6 +1078,26 @@ async function finalizeCronRun(params: {
})
).preferFinalAssistantVisibleText,
});
if (finalRunResult.meta?.aborted === true && !cronPayloadOutcome.hasFatalErrorPayload) {
const metaErrorMessage = normalizeOptionalString(finalRunResult.meta.error?.message);
const error = metaErrorMessage ?? "cron isolated agent run aborted";
const { cleanupDirectCronSession } = await loadCronDeliveryRuntime();
await cleanupDirectCronSession({
job: prepared.input.job,
agentSessionKey: prepared.agentSessionKey,
sessionId: prepared.currentRunSessionId(),
retireReason: "cron-delete-after-run-aborted",
});
return prepared.withRunSession({
status: "error",
error,
diagnostics: mergeCronRunDiagnostics(
createCronRunDiagnosticsFromAgentResult(finalRunResult, { finalStatus: "error" }),
createCronRunDiagnosticsFromError("agent-run", error),
),
...telemetry,
});
}
const {
synthesizedText,
deliveryPayloads,
@@ -29,6 +29,7 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
}));
vi.mock("./session-transcript-files.fs.js", () => ({
extractGeneratedTranscriptSessionId: vi.fn(() => undefined),
resolveStableSessionEndTranscript: vi.fn(() => ({
sessionFile: undefined,
transcriptArchived: false,
@@ -372,6 +372,56 @@ test("sessions.reset rotates generated topic transcript files with the new sessi
expect(path.basename(persistedEntry?.sessionFile ?? "")).toBe(`${nextSessionId}-topic-456.jsonl`);
});
test("sessions.reset rotates an already-stale generated transcript file to the new session id", async () => {
const { dir, storePath } = await createSessionStoreDir();
// Post-upgrade state: the stored sessionFile still embeds an OLDER generated id
// that no longer matches the entry's logical sessionId, so rotation must key off
// the file's embedded id rather than the current sessionId (issue #77770).
const staleFileSessionId = "11111111-1111-4111-8111-111111111111";
const currentSessionId = "22222222-2222-4222-8222-222222222222";
const staleSessionFile = path.join(dir, `${staleFileSessionId}.jsonl`);
await fs.writeFile(staleSessionFile, `${JSON.stringify({ role: "user", content: "old" })}\n`);
await writeSessionStore({
entries: {
main: sessionStoreEntry(currentSessionId, {
sessionFile: staleSessionFile,
}),
},
});
const reset = await directSessionReq<{
ok: true;
key: string;
entry: {
sessionId: string;
sessionFile?: string;
};
}>("sessions.reset", { key: "main" });
expect(reset.ok).toBe(true);
const nextSessionId = reset.payload?.entry.sessionId;
const nextSessionFile = reset.payload?.entry.sessionFile;
if (!nextSessionId || !nextSessionFile) {
throw new Error("expected reset session id and file");
}
expect(nextSessionId).not.toBe(currentSessionId);
// The new session must adopt the new session id, not keep the stale generated name.
expect(path.basename(nextSessionFile)).toBe(`${nextSessionId}.jsonl`);
expect(path.basename(nextSessionFile)).not.toBe(`${staleFileSessionId}.jsonl`);
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{
sessionId?: string;
sessionFile?: string;
}
>;
const persistedEntry = store["agent:main:main"];
expect(persistedEntry?.sessionId).toBe(nextSessionId);
expect(path.basename(persistedEntry?.sessionFile ?? "")).toBe(`${nextSessionId}.jsonl`);
});
test("sessions.reset preserves legacy explicit model overrides without modelOverrideSource", async () => {
await expectMainResetModelFields({
defaultPrimary: "openai/gpt-test-a",
+10 -5
View File
@@ -61,6 +61,7 @@ import {
import { findDirectChildSessionsForParent } from "./session-child-sessions.js";
import {
archiveSessionTranscriptsDetailed,
extractGeneratedTranscriptSessionId,
resolveStableSessionEndTranscript,
type ArchivedSessionTranscript,
} from "./session-transcript-files.fs.js";
@@ -82,12 +83,16 @@ function resolveResetSessionFile(params: {
agentId: string;
}): string {
const currentEntry = params.currentEntry;
// Preserve explicit session-file placement across reset while swapping the
// embedded session id, so linked runtimes keep writing beside old transcripts.
const rewrittenSessionFile = currentEntry?.sessionId
// Rotate generated transcript names by the file's *embedded* id, not the logical
// session id: a post-upgrade sessionFile can embed a stale id, so keying off
// currentEntry.sessionId would orphan the reset session on the old file. Explicit
// custom placements have no embedded id and stay preserved.
const rotationPreviousSessionId =
extractGeneratedTranscriptSessionId(currentEntry?.sessionFile) ?? currentEntry?.sessionId;
const rewrittenSessionFile = rotationPreviousSessionId
? rewriteSessionFileForNewSessionId({
sessionFile: currentEntry.sessionFile,
previousSessionId: currentEntry.sessionId,
sessionFile: currentEntry?.sessionFile,
previousSessionId: rotationPreviousSessionId,
nextSessionId: params.nextSessionId,
})
: undefined;
+1 -1
View File
@@ -106,7 +106,7 @@ function classifySessionTranscriptCandidate(
return transcriptSessionId === sessionId ? "current" : "stale";
}
function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined {
export function extractGeneratedTranscriptSessionId(sessionFile?: string): string | undefined {
const trimmed = sessionFile?.trim();
if (!trimmed) {
return undefined;
@@ -706,6 +706,7 @@ describe("runHeartbeatOnce", () => {
options?: {
nowMs?: number;
getReplyFromConfig?: HeartbeatDeps["getReplyFromConfig"];
listActiveEmbeddedRunSessionKeys?: HeartbeatDeps["listActiveEmbeddedRunSessionKeys"];
},
): HeartbeatDeps => ({
whatsapp: sendWhatsApp,
@@ -714,6 +715,9 @@ describe("runHeartbeatOnce", () => {
webAuthExists: async () => true,
hasActiveWebListener: () => true,
...(options?.getReplyFromConfig ? { getReplyFromConfig: options.getReplyFromConfig } : null),
...(options?.listActiveEmbeddedRunSessionKeys
? { listActiveEmbeddedRunSessionKeys: options.listActiveEmbeddedRunSessionKeys }
: null),
});
it("skips when agent heartbeat is not enabled", async () => {
@@ -731,6 +735,33 @@ describe("runHeartbeatOnce", () => {
}
});
it.each([
["the heartbeat main session", (cfg: OpenClawConfig) => resolveMainSessionKey(cfg)],
["another session for the same agent", () => "agent:main:telegram:alerts"],
])("retries instead of dispatching while %s has an embedded run", async (_name, activeKey) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: { every: "5m", target: "none" },
},
},
};
const replySpy = vi.fn().mockResolvedValue({ text: "heartbeat reply" });
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
const res = await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(sendWhatsApp, {
getReplyFromConfig: replySpy,
listActiveEmbeddedRunSessionKeys: () => [activeKey(cfg)],
}),
});
expect(res).toEqual({ status: "skipped", reason: "requests-in-flight" });
expect(replySpy).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
});
it("skips outside active hours", async () => {
const cfg: OpenClawConfig = {
agents: {
@@ -1,6 +1,7 @@
// Covers heartbeat skipping while session lanes or cron jobs are busy.
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveNestedAgentLaneForSession } from "../agents/lanes.js";
import { resolveReplyOperationRunState } from "../auto-reply/reply/reply-operation-run-state.js";
import {
__testing as replyRunRegistryTesting,
createReplyOperation,
@@ -360,6 +361,44 @@ describe("heartbeat runner skips when target session lane is busy", () => {
});
});
it("does not infer admission rejection from a replacement run after an empty heartbeat", async () => {
await withTempHeartbeatSandbox(async ({ storePath }) => {
const cfg = createHeartbeatTelegramConfig();
const sessionKey = await seedHeartbeatTelegramSession(storePath, cfg);
let operation: ReturnType<typeof createReplyOperation> | undefined;
const replySpy = vi.fn(async (_ctx, replyOptions) => {
const runState = resolveReplyOperationRunState(replyOptions);
if (!runState) {
throw new Error("expected heartbeat reply operation state");
}
runState.admission = { status: "owned" };
operation = createReplyOperation({
sessionKey,
sessionId: "racing-visible-session",
resetTriggered: false,
});
operation.setPhase("running");
return undefined;
});
try {
const result = await runHeartbeatOnce({
cfg,
deps: {
getQueueSize: vi.fn((_lane?: string) => 0),
nowMs: () => Date.now(),
getReplyFromConfig: replySpy,
} as HeartbeatDeps,
});
expect(result.status).toBe("ran");
expect(replySpy).toHaveBeenCalledOnce();
} finally {
operation?.complete();
}
});
});
it("returns requests-in-flight when an isolated heartbeat reply run is still active", async () => {
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig();
+42 -7
View File
@@ -20,6 +20,7 @@ import {
} from "../agents/agent-scope.js";
import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js";
import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js";
import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
@@ -43,6 +44,10 @@ import {
} from "../auto-reply/heartbeat.js";
import { replaceGenericExternalRunFailureText } from "../auto-reply/reply/agent-runner-failure-copy.js";
import { resolveDefaultModel } from "../auto-reply/reply/directive-handling.defaults.js";
import {
REPLY_OPERATION_RUN_STATE,
type ReplyOperationRunState,
} from "../auto-reply/reply/reply-operation-run-state.js";
import {
listActiveReplyRunSessionKeys,
replyRunRegistry,
@@ -155,6 +160,7 @@ export type HeartbeatDeps = OutboundSendDeps &
getCommandLaneSnapshots?: () => readonly CommandLaneSnapshot[];
isReplyRunActive?: (sessionKey: string) => boolean;
listActiveReplyRunSessionKeys?: () => readonly string[];
listActiveEmbeddedRunSessionKeys?: () => readonly string[];
nowMs?: () => number;
};
@@ -229,10 +235,7 @@ function hasAgentOptInBusyLaneWork(
return hasQueuedWorkInLaneSnapshots(getSnapshots(), (lane) => laneBelongsToAgent(lane, agentId));
}
function hasActiveReplyRunForAgent(
agentId: string,
listSessionKeys: () => readonly string[],
): boolean {
function hasActiveRunForAgent(agentId: string, listSessionKeys: () => readonly string[]): boolean {
const normalizedAgentId = normalizeAgentId(agentId);
return listSessionKeys().some((sessionKey) => {
const parsed = parseAgentSessionKey(sessionKey);
@@ -240,6 +243,14 @@ function hasActiveReplyRunForAgent(
});
}
function hasActiveRunForSession(
sessionKey: string,
listSessionKeys: () => readonly string[],
): boolean {
const normalizedSessionKey = sessionKey.trim();
return Boolean(normalizedSessionKey) && listSessionKeys().includes(normalizedSessionKey);
}
function resolveHeartbeatChannelPlugin(channel: string): ChannelPlugin | undefined {
const activePlugin = getActivePluginChannelRegistry()?.channels.find(
(entry) => entry.plugin.id === channel,
@@ -1358,10 +1369,16 @@ export async function runHeartbeatOnce(opts: {
const shouldHonorActiveReplyRuns = opts.intent !== "immediate" && opts.intent !== "manual";
const listActiveReplyRuns =
opts.deps?.listActiveReplyRunSessionKeys ?? listActiveReplyRunSessionKeys;
const listActiveEmbeddedRuns =
opts.deps?.listActiveEmbeddedRunSessionKeys ?? listActiveEmbeddedRunSessionKeys;
// Scheduled heartbeats are background work, so defer them when any session on
// the same agent is already replying; immediate/manual wakes keep their
// existing semantics for explicit user/system actions.
if (shouldHonorActiveReplyRuns && hasActiveReplyRunForAgent(agentId, listActiveReplyRuns)) {
if (
shouldHonorActiveReplyRuns &&
(hasActiveRunForAgent(agentId, listActiveReplyRuns) ||
hasActiveRunForAgent(agentId, listActiveEmbeddedRuns))
) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1417,7 +1434,7 @@ export async function runHeartbeatOnce(opts: {
const { entry, sessionKey, storePath, suppressOriginatingContext } = preflight.session;
const isReplyRunActive =
opts.deps?.isReplyRunActive ?? ((key: string) => replyRunRegistry.isActive(key));
if (isReplyRunActive(sessionKey)) {
if (isReplyRunActive(sessionKey) || hasActiveRunForSession(sessionKey, listActiveEmbeddedRuns)) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1576,7 +1593,10 @@ export async function runHeartbeatOnce(opts: {
isolatedSessionKey,
isolatedBaseSessionKey,
});
if (isReplyRunActive(isolatedSessionKey)) {
if (
isReplyRunActive(isolatedSessionKey) ||
hasActiveRunForSession(isolatedSessionKey, listActiveEmbeddedRuns)
) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
@@ -1781,8 +1801,10 @@ export async function runHeartbeatOnce(opts: {
const timeoutOverrideSeconds = resolveHeartbeatTimeoutOverrideSeconds(cfg, heartbeat);
const bootstrapContextMode: "lightweight" | undefined =
heartbeat?.lightContext === true ? "lightweight" : undefined;
const replyOperationRunState: ReplyOperationRunState = {};
const replyOpts = {
isHeartbeat: true,
[REPLY_OPERATION_RUN_STATE]: replyOperationRunState,
...(heartbeatModelOverride ? { heartbeatModelOverride } : {}),
suppressToolErrorWarnings,
...(usesHeartbeatResponseTool ? { enableHeartbeatTool: true, forceHeartbeatTool: true } : {}),
@@ -1800,6 +1822,19 @@ export async function runHeartbeatOnce(opts: {
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
const heartbeatToolResponse = resolveHeartbeatToolResponseFromReplyResult(replyResult);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
if (
!heartbeatToolResponse &&
(!replyPayload || !hasOutboundReplyContent(replyPayload)) &&
replyOperationRunState.admission?.status === "skipped" &&
replyOperationRunState.admission.reason === "active-run"
) {
emitHeartbeatEvent({
status: "skipped",
reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT,
durationMs: Date.now() - startedAt,
});
return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT };
}
const includeReasoning = heartbeat?.includeReasoning === true;
const reasoningPayloads = includeReasoning
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
+62
View File
@@ -9,6 +9,7 @@ import type { CommandOptions } from "../process/exec.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import {
listMissingRequiredPlatformPackages,
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
@@ -99,6 +100,67 @@ function requireCommandOptions(
}
describe("managed npm root", () => {
it("finds explicitly required optional packages for the current platform", async () => {
const npmRoot = await makeTempRoot();
const matchingPackage = "@vendor/tool-platform";
const scriptedPackage = "@vendor/tool-scripted";
const foreignPackage = "@vendor/tool-foreign";
const unconstrainedPackage = "@vendor/tool-optional";
const unlistedPackage = "@vendor/tool-unlisted";
await fs.writeFile(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify({
lockfileVersion: 3,
packages: {
"": {},
[`node_modules/${matchingPackage}`]: {
optional: true,
os: [process.platform],
cpu: [process.arch],
},
[`node_modules/${scriptedPackage}`]: {
optional: true,
hasInstallScript: true,
os: [process.platform],
cpu: [process.arch],
},
[`node_modules/${foreignPackage}`]: {
optional: true,
os: [`not-${process.platform}`],
cpu: [process.arch],
},
[`node_modules/${unconstrainedPackage}`]: {
optional: true,
},
[`node_modules/${unlistedPackage}`]: {
optional: true,
os: [process.platform],
cpu: [process.arch],
},
},
})}\n`,
);
await expect(
listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: [
matchingPackage,
scriptedPackage,
foreignPackage,
unconstrainedPackage,
],
}),
).resolves.toEqual(
[matchingPackage, scriptedPackage]
.map((name) => ({
name,
packagePath: path.join(npmRoot, "node_modules", ...name.split("/")),
}))
.toSorted((left, right) => left.packagePath.localeCompare(right.packagePath)),
);
});
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
+74 -7
View File
@@ -374,13 +374,11 @@ function isUnsupportedOptionalLockPackage(value: unknown): boolean {
);
}
function readLockPackageName(location: string, value: unknown): string | undefined {
if (isRecord(value)) {
const packageName = readOptionalString(value.name);
if (packageName) {
return packageName;
}
}
function hasNpmPlatformConstraint(value: Record<string, unknown>): boolean {
return value.os !== undefined || value.cpu !== undefined || value.libc !== undefined;
}
function readLockPackageLocationName(location: string): string | undefined {
const parts = location.split("/");
for (let index = parts.length - 1; index >= 0; index -= 1) {
if (parts[index] !== "node_modules") {
@@ -399,10 +397,79 @@ function readLockPackageName(location: string, value: unknown): string | undefin
return undefined;
}
function readLockPackageName(location: string, value: unknown): string | undefined {
if (isRecord(value)) {
const packageName = readOptionalString(value.name);
if (packageName) {
return packageName;
}
}
return readLockPackageLocationName(location);
}
function resolveManagedNpmLockPackagePath(params: {
npmRoot: string;
location: string;
}): string | undefined {
const npmRoot = path.resolve(params.npmRoot);
const packagePath = path.resolve(npmRoot, ...params.location.split("/"));
const relativePath = path.relative(npmRoot, packagePath);
if (
!relativePath ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
return undefined;
}
return packagePath;
}
function isTopLevelLockPackageLocation(location: string): boolean {
return location.split("/").filter((part) => part === "node_modules").length === 1;
}
export type MissingRequiredPlatformPackage = {
name: string;
packagePath: string;
};
/** Lists explicitly required current-platform packages that npm recorded but did not materialize. */
export async function listMissingRequiredPlatformPackages(params: {
npmRoot: string;
requiredPackageNames: ReadonlySet<string> | readonly string[];
}): Promise<MissingRequiredPlatformPackage[]> {
const requiredPackageNames = new Set(params.requiredPackageNames);
if (requiredPackageNames.size === 0) {
return [];
}
const lockPath = path.join(params.npmRoot, "package-lock.json");
const parsed = await readJson<unknown>(lockPath);
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
return [];
}
const missing: MissingRequiredPlatformPackage[] = [];
for (const [location, value] of Object.entries(parsed.packages)) {
if (
!isRecord(value) ||
value.optional !== true ||
!hasNpmPlatformConstraint(value) ||
isUnsupportedOptionalLockPackage(value)
) {
continue;
}
const name = readLockPackageLocationName(location);
const packagePath = resolveManagedNpmLockPackagePath({ npmRoot: params.npmRoot, location });
if (!name || !requiredPackageNames.has(name) || !isSafePackageName(name) || !packagePath) {
continue;
}
if (!(await pathExists(packagePath))) {
missing.push({ name, packagePath });
}
}
return missing.toSorted((left, right) => left.packagePath.localeCompare(right.packagePath));
}
function findLockPackageVersion(params: {
lockfile: ManagedNpmRootLockfile;
packageName: string;
+85 -1
View File
@@ -13,7 +13,9 @@ import {
const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();
let applyPluginDoctorCompatibilityMigrations: typeof import("./doctor-contract-registry.js").applyPluginDoctorCompatibilityMigrations;
let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache;
let collectRelevantDoctorPluginIds: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIds;
let collectRelevantDoctorPluginIdsForTouchedPaths: typeof import("./doctor-contract-registry.js").collectRelevantDoctorPluginIdsForTouchedPaths;
let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules;
let listPluginDoctorSessionRouteStateOwners: typeof import("./doctor-contract-registry.js").listPluginDoctorSessionRouteStateOwners;
@@ -43,7 +45,9 @@ describe("doctor-contract-registry module loader", () => {
resetRegistryJitiMocks();
vi.resetModules();
({
applyPluginDoctorCompatibilityMigrations,
clearPluginDoctorContractRegistryCache,
collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
listPluginDoctorSessionRouteStateOwners,
@@ -347,6 +351,80 @@ describe("doctor-contract-registry module loader", () => {
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2);
});
it("collects model provider ids for doctor compatibility migrations", () => {
expect(
collectRelevantDoctorPluginIds({
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
},
},
},
}),
).toEqual(["ollama-cloud"]);
});
it("loads a plugin doctor contract when scoped by a contributed provider id", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "doctor-contract-api.ts"), "export {};\n", "utf-8");
mocks.createJiti.mockImplementation(() => () => ({
normalizeCompatibilityConfig: ({
cfg,
}: {
cfg: { models?: { providers?: Record<string, Record<string, unknown>> } };
}) => ({
config: {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
"ollama-cloud": {
...cfg.models?.providers?.["ollama-cloud"],
baseUrl: "https://ollama.com",
},
},
},
},
changes: ["normalized ollama cloud provider endpoint"],
}),
}));
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "ollama",
rootDir: pluginRoot,
channels: [],
providers: ["ollama", "ollama-cloud"],
},
],
diagnostics: [],
});
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
models: [],
},
},
},
};
const result = applyPluginDoctorCompatibilityMigrations(config, {
config,
env: {},
pluginIds: ["ollama-cloud"],
});
expect(result.changes).toEqual(["normalized ollama cloud provider endpoint"]);
expect(result.config.models?.providers?.["ollama-cloud"]).toEqual({
baseUrl: "https://ollama.com",
models: [],
});
});
it("narrows touched-path doctor ids for scoped dry-run validation", () => {
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({
@@ -360,6 +438,11 @@ describe("doctor-contract-registry module loader", () => {
"memory-wiki": {},
},
},
models: {
providers: {
"ollama-cloud": {},
},
},
talk: {
voiceId: "legacy-voice",
},
@@ -367,10 +450,11 @@ describe("doctor-contract-registry module loader", () => {
touchedPaths: [
["channels", "discord", "token"],
["plugins", "entries", "memory-wiki", "enabled"],
["models", "providers", "ollama-cloud", "baseUrl"],
["talk", "voiceId"],
],
}),
).toEqual(["discord", "elevenlabs", "memory-wiki"]);
).toEqual(["discord", "elevenlabs", "memory-wiki", "ollama-cloud"]);
});
it("falls back to the full doctor-id set when touched paths are too broad", () => {
+14
View File
@@ -244,6 +244,13 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
}
}
const modelProviders = asNullableRecord(asNullableRecord(root.models)?.providers);
if (modelProviders) {
for (const providerId of Object.keys(modelProviders)) {
ids.add(providerId);
}
}
if (hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
@@ -279,6 +286,13 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
ids.add(third);
continue;
}
if (first === "models") {
if (second !== "providers" || !third) {
return collectRelevantDoctorPluginIds(params.raw);
}
ids.add(third);
continue;
}
if (first === "talk" && hasLegacyElevenLabsTalkFields(root)) {
ids.add("elevenlabs");
}
+157
View File
@@ -287,6 +287,30 @@ function writeNpmRootPackageLock(params: {
);
}
function writeMissingCurrentPlatformOptionalPackage(params: {
npmRoot: string;
packageName: string;
packageLocation: string;
}): void {
const lockPath = path.join(params.npmRoot, "package-lock.json");
const lockfile = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
packages?: Record<string, unknown>;
};
lockfile.packages ??= {};
lockfile.packages[params.packageLocation] = {
name: params.packageName,
version: "1.0.0-platform",
optional: true,
os: [process.platform],
cpu: [process.arch],
};
fs.writeFileSync(lockPath, `${JSON.stringify(lockfile, null, 2)}\n`, "utf8");
fs.rmSync(path.join(params.npmRoot, ...params.packageLocation.split("/")), {
recursive: true,
force: true,
});
}
function readTextFileTree(dir: string, rootDir = dir): Record<string, string> {
return Object.fromEntries(
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -950,6 +974,139 @@ describe("installPluginFromNpmSpec", () => {
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
});
it("repairs omitted current-platform packages with a fresh npm cache", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
const packageName = "@openclaw/codex-fixture";
const platformPackage = "@vendor/codex-platform";
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
const platformPackageLocation = path.posix.join(
"node_modules",
packageName,
"node_modules",
platformPackage,
);
const warnings: string[] = [];
mockNpmViewAndInstall({
spec: `${packageName}@1.0.0`,
packageName,
version: "1.0.0",
pluginId: "codex-fixture",
npmRoot,
expectedDependencySpec: "1.0.0",
openclaw: {
extensions: ["./dist/index.js"],
install: { requiredPlatformPackages: [platformPackage] },
},
});
const delegate = runCommandWithTimeoutMock.getMockImplementation();
if (!delegate) {
throw new Error("expected npm mock implementation");
}
let managedInstallAttempts = 0;
let repairCacheDir = "";
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
const result = await delegate(argv, options);
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
managedInstallAttempts += 1;
if (managedInstallAttempts === 1) {
writeMissingCurrentPlatformOptionalPackage({
npmRoot: npmProjectRoot,
packageName: platformPackage,
packageLocation: platformPackageLocation,
});
} else {
repairCacheDir = options.env?.npm_config_cache ?? "";
const packageDir = path.join(npmProjectRoot, ...platformPackageLocation.split("/"));
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: platformPackage, version: "1.0.0-platform" }),
"utf8",
);
}
}
return result;
},
);
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: (message) => warnings.push(message) },
});
expect(result.ok).toBe(true);
expect(managedInstallAttempts).toBe(2);
expect(repairCacheDir).toContain("openclaw-npm-cache-");
expect(fs.existsSync(repairCacheDir)).toBe(false);
expect(warnings).toContain(
`npm omitted current-platform package(s) ${platformPackage}; retrying once with a fresh cache.`,
);
});
it("rejects installs that still omit current-platform packages after repair", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
const packageName = "@openclaw/codex-fixture";
const platformPackage = "@vendor/codex-platform";
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
const platformPackageLocation = path.posix.join(
"node_modules",
packageName,
"node_modules",
platformPackage,
);
mockNpmViewAndInstall({
spec: `${packageName}@1.0.0`,
packageName,
version: "1.0.0",
pluginId: "codex-fixture",
npmRoot,
expectedDependencySpec: "1.0.0",
openclaw: {
extensions: ["./dist/index.js"],
install: { requiredPlatformPackages: [platformPackage] },
},
});
const delegate = runCommandWithTimeoutMock.getMockImplementation();
if (!delegate) {
throw new Error("expected npm mock implementation");
}
let managedInstallAttempts = 0;
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string }) => {
const result = await delegate(argv, options);
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
managedInstallAttempts += 1;
writeMissingCurrentPlatformOptionalPackage({
npmRoot: npmProjectRoot,
packageName: platformPackage,
packageLocation: platformPackageLocation,
});
}
return result;
},
);
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(managedInstallAttempts).toBe(2);
expect(result.error).toContain(
`npm install reported success but omitted required current-platform package(s): ${platformPackage}`,
);
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
});
it("quarantines and rebuilds a corrupt managed npm project after npm from-argument failures", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
+123
View File
@@ -18,6 +18,7 @@ import {
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
type ManagedNpmRootPeerDependencySnapshot,
listMissingRequiredPlatformPackages,
readManagedNpmRootInstalledDependency,
readManagedNpmRootPeerDependencySnapshot,
readOpenClawManagedNpmRootOverrides,
@@ -1070,6 +1071,41 @@ function resolveManagedNpmRootPackageDir(npmRoot: string, packageName: string):
return path.join(npmRoot, "node_modules", ...packageName.split("/"));
}
function resolveRequiredPlatformPackageNames(
packageMetadata?: OpenClawPackageManifest,
): { ok: true; packageNames: string[] } | { ok: false; error: string } {
const raw = packageMetadata?.install?.requiredPlatformPackages as unknown;
if (raw === undefined) {
return { ok: true, packageNames: [] };
}
if (!Array.isArray(raw)) {
return {
ok: false,
error: "package.json openclaw.install.requiredPlatformPackages must be an array",
};
}
const packageNames = new Set<string>();
for (const value of raw) {
if (typeof value !== "string") {
return {
ok: false,
error:
"package.json openclaw.install.requiredPlatformPackages must contain only npm package names",
};
}
const specError = validateRegistryNpmSpec(value);
const parsed = parseRegistryNpmSpec(value);
if (specError || !parsed || parsed.selectorKind !== "none") {
return {
ok: false,
error: `package.json openclaw.install.requiredPlatformPackages contains invalid package name: ${value}`,
};
}
packageNames.add(parsed.name);
}
return { ok: true, packageNames: [...packageNames] };
}
async function listNewManagedNpmRootPackageDirs(params: {
beforeInstallPackageNames: Set<string>;
npmRoot: string;
@@ -1407,6 +1443,93 @@ async function installPluginFromManagedNpmRoot(
"npm install could not settle managed peer dependencies after 10 sync passes; refusing to leave a partially reconciled plugin dependency tree.",
});
}
const packageManifestResult = await readOptionalPackageManifest({
runtime,
packageDir: installRoot,
});
if (!packageManifestResult.ok) {
return await rollbackFailedManagedNpmInstall(packageManifestResult);
}
const requiredPlatformPackageNames = resolveRequiredPlatformPackageNames(
packageManifestResult.manifest
? runtime.getPackageManifestMetadata(packageManifestResult.manifest)
: undefined,
);
if (!requiredPlatformPackageNames.ok) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: requiredPlatformPackageNames.error,
});
}
let omittedPlatformPackages: Awaited<ReturnType<typeof listMissingRequiredPlatformPackages>>;
try {
omittedPlatformPackages = await listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: requiredPlatformPackageNames.packageNames,
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to verify platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
});
}
if (omittedPlatformPackages.length > 0) {
const omittedPlatformPackageNames = omittedPlatformPackages.map((entry) => entry.name);
logger.warn?.(
`npm omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}; retrying once with a fresh cache.`,
);
let freshCacheDir: string | undefined;
try {
freshCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-cache-"));
install = await runCommandWithTimeout(npmInstallArgs, {
...npmInstallOptions,
env: {
...npmInstallOptions.env,
NPM_CONFIG_CACHE: freshCacheDir,
npm_config_cache: freshCacheDir,
},
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to repair omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${String(error)}`,
});
} finally {
if (freshCacheDir) {
try {
await fs.rm(freshCacheDir, { recursive: true, force: true });
} catch (error) {
logger.warn?.(
`Failed to remove temporary npm cache ${freshCacheDir}: ${String(error)}`,
);
}
}
}
if (install.code !== 0) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `npm install failed while repairing omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${formatNpmCommandFailureOutput(install)}`,
});
}
let stillOmittedPlatformPackages: typeof omittedPlatformPackages;
try {
stillOmittedPlatformPackages = await listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: requiredPlatformPackageNames.packageNames,
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to verify repaired platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
});
}
if (stillOmittedPlatformPackages.length > 0) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `npm install reported success but omitted required current-platform package(s): ${stillOmittedPlatformPackages.map((entry) => entry.name).join(", ")}`,
});
}
}
if (params.packageName !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
+1
View File
@@ -1956,6 +1956,7 @@ export type PluginPackageInstall = {
minHostVersion?: string;
expectedIntegrity?: string;
allowInvalidConfigRecovery?: boolean;
requiredPlatformPackages?: string[];
};
export type OpenClawPackageStartup = {
+44
View File
@@ -0,0 +1,44 @@
// Skill refresh state tests cover snapshot version invalidation contracts.
import { beforeEach, describe, expect, it } from "vitest";
import {
bumpSkillsSnapshotVersion,
getSkillsSnapshotVersion,
resetSkillsRefreshStateForTest,
shouldRefreshSnapshotForVersion,
} from "./refresh-state.js";
describe("skills refresh state", () => {
beforeEach(() => {
resetSkillsRefreshStateForTest();
});
it("starts above persisted version 0 so restarted sessions refresh once", () => {
const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
expect(currentVersion).toBeGreaterThan(0);
expect(shouldRefreshSnapshotForVersion(0, currentVersion)).toBe(true);
});
it("starts above persisted timestamp versions from earlier processes", () => {
const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
const previousProcessVersion = currentVersion - 1;
expect(shouldRefreshSnapshotForVersion(previousProcessVersion, currentVersion)).toBe(true);
});
it("reuses snapshots already built for the current startup version", () => {
const currentVersion = getSkillsSnapshotVersion("/tmp/workspace");
expect(shouldRefreshSnapshotForVersion(currentVersion, currentVersion)).toBe(false);
});
it("keeps workspace and global bumps above the startup version", () => {
const startupVersion = getSkillsSnapshotVersion("/tmp/workspace");
const workspaceVersion = bumpSkillsSnapshotVersion({ workspaceDir: "/tmp/workspace" });
const globalVersion = bumpSkillsSnapshotVersion();
expect(workspaceVersion).toBeGreaterThan(startupVersion);
expect(globalVersion).toBeGreaterThanOrEqual(workspaceVersion);
expect(getSkillsSnapshotVersion("/tmp/workspace")).toBe(globalVersion);
});
});
+3 -2
View File
@@ -7,7 +7,8 @@ export type SkillsChangeEvent = {
const listeners = new Set<(event: SkillsChangeEvent) => void>();
const workspaceVersions = new Map<string, number>();
let globalVersion = 0;
const INITIAL_SKILLS_SNAPSHOT_VERSION = Date.now();
let globalVersion = INITIAL_SKILLS_SNAPSHOT_VERSION;
let listenerErrorHandler: ((err: unknown) => void) | undefined;
function bumpVersion(current: number): number {
@@ -85,6 +86,6 @@ export function shouldRefreshSnapshotForVersion(
export function resetSkillsRefreshStateForTest(): void {
listeners.clear();
workspaceVersions.clear();
globalVersion = 0;
globalVersion = INITIAL_SKILLS_SNAPSHOT_VERSION;
listenerErrorHandler = undefined;
}
+47 -10
View File
@@ -6,11 +6,11 @@ import type { SkillSnapshot } from "../types.js";
const TEST_WORKSPACE_DIR = "/tmp/workspace";
function strippedSnapshot(skillName = "test"): SkillSnapshot {
function strippedSnapshot(skillName = "test", version = 1): SkillSnapshot {
return {
prompt: "skills prompt",
skills: [{ name: skillName }],
version: 0,
version,
promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION,
};
}
@@ -27,8 +27,10 @@ const {
resolvedSkills: [] as unknown[],
})),
ensureSkillsWatcherMock: vi.fn(),
getSkillsSnapshotVersionMock: vi.fn(() => 0),
shouldRefreshSnapshotForVersionMock: vi.fn((_cached?: number, _next?: number) => false),
getSkillsSnapshotVersionMock: vi.fn(() => 1),
shouldRefreshSnapshotForVersionMock: vi.fn((cached = 0, next = 0) =>
next === 0 ? cached > 0 : cached < next,
),
}));
vi.mock("../loading/workspace.js", () => ({
@@ -52,8 +54,10 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
vi.clearAllMocks();
resetResolvedSkillsCacheForTests();
buildWorkspaceSkillSnapshotMock.mockReturnValue({ prompt: "", skills: [], resolvedSkills: [] });
getSkillsSnapshotVersionMock.mockReturnValue(0);
shouldRefreshSnapshotForVersionMock.mockReturnValue(false);
getSkillsSnapshotVersionMock.mockReturnValue(1);
shouldRefreshSnapshotForVersionMock.mockImplementation((cached = 0, next = 0) =>
next === 0 ? cached > 0 : cached < next,
);
});
it("reuses cached resolvedSkills across calls with the same workspace, version, and filter", () => {
@@ -97,19 +101,18 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
});
it("reads the skills snapshot version after watcher-side invalidation", () => {
getSkillsSnapshotVersionMock.mockReturnValue(0);
getSkillsSnapshotVersionMock.mockReturnValue(1);
ensureSkillsWatcherMock.mockImplementation(() => {
getSkillsSnapshotVersionMock.mockReturnValue(5);
});
shouldRefreshSnapshotForVersionMock.mockImplementation((cached = 0, next = 0) => cached < next);
resolveReusableWorkspaceSkillSnapshot({
workspaceDir: TEST_WORKSPACE_DIR,
config: { skills: { load: { extraDirs: ["/tmp/shared-skills"] } } },
existingSnapshot: strippedSnapshot(),
existingSnapshot: strippedSnapshot("test", 1),
});
expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(0, 5);
expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(1, 5);
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
[string, { snapshotVersion?: number }]
@@ -117,6 +120,40 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
expect(snapshotParams.snapshotVersion).toBe(5);
});
it("refreshes persisted version-0 snapshots after process restart", () => {
const result = resolveReusableWorkspaceSkillSnapshot({
workspaceDir: TEST_WORKSPACE_DIR,
config: {},
existingSnapshot: strippedSnapshot("test", 0),
});
expect(result.shouldRefresh).toBe(true);
expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(0, 1);
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
[string, { snapshotVersion?: number }]
>;
expect(snapshotParams.snapshotVersion).toBe(1);
});
it("refreshes persisted timestamp-version snapshots from earlier processes", () => {
getSkillsSnapshotVersionMock.mockReturnValue(10_000);
const result = resolveReusableWorkspaceSkillSnapshot({
workspaceDir: TEST_WORKSPACE_DIR,
config: {},
existingSnapshot: strippedSnapshot("test", 9_999),
});
expect(result.shouldRefresh).toBe(true);
expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(9_999, 10_000);
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
[string, { snapshotVersion?: number }]
>;
expect(snapshotParams.snapshotVersion).toBe(10_000);
});
it("invalidates cached resolvedSkills when non-skills config gates change", () => {
buildWorkspaceSkillSnapshotMock.mockImplementation((_workspaceDir, opts) => {
const config = (opts as { config?: { channels?: { discord?: { token?: string } } } }).config;
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
"screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
"screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
@@ -17,6 +17,7 @@
"camera_clip",
"photos_latest",
"screen_record",
"screen_snapshot",
"location_get",
"notifications_list",
"notifications_action",
@@ -227,8 +227,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 45244,
"roughTokens": 11311
"chars": 45275,
"roughTokens": 11319
},
"openClawDeveloperInstructions": {
"chars": 2988,
@@ -239,8 +239,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6925
},
"totalWithDynamicToolsJson": {
"chars": 72946,
"roughTokens": 18237
"chars": 72977,
"roughTokens": 18245
},
"userInputText": {
"chars": 1629,
@@ -227,8 +227,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 44933,
"roughTokens": 11234
"chars": 44964,
"roughTokens": 11241
},
"openClawDeveloperInstructions": {
"chars": 1964,
@@ -239,8 +239,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6544
},
"totalWithDynamicToolsJson": {
"chars": 71111,
"roughTokens": 17778
"chars": 71142,
"roughTokens": 17786
},
"userInputText": {
"chars": 1129,
@@ -228,8 +228,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 46028,
"roughTokens": 11507
"chars": 46059,
"roughTokens": 11515
},
"openClawDeveloperInstructions": {
"chars": 1983,
@@ -240,8 +240,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6780
},
"totalWithDynamicToolsJson": {
"chars": 73149,
"roughTokens": 18288
"chars": 73180,
"roughTokens": 18295
},
"userInputText": {
"chars": 1367,
+13
View File
@@ -11,8 +11,10 @@ import {
canStartSchedulerLane,
describeDockerSchedulerLimits,
dockerPreflightContainerNames,
dockerPreflightSmokeCommand,
LOG_TAIL_MAX_BYTES,
parseDockerAllCliArgs,
resolveDockerPreflightPlatform,
runShellCommand,
SHELL_CAPTURE_MAX_CHARS,
tailFile,
@@ -417,6 +419,17 @@ postgres Created
]);
});
it("pins Docker preflight smoke to the native platform", () => {
expect(resolveDockerPreflightPlatform("x64")).toBe("linux/amd64");
expect(resolveDockerPreflightPlatform("arm64")).toBe("linux/arm64");
expect(dockerPreflightSmokeCommand("x64")).toBe(
"docker run --rm --platform 'linux/amd64' alpine:3.20 true",
);
expect(dockerPreflightSmokeCommand("arm64")).toBe(
"docker run --rm --platform 'linux/arm64' alpine:3.20 true",
);
});
it("bounds captured preflight command output while keeping the newest tail", () => {
const first = appendBoundedShellCapture("abc", "def", 8);
expect(first).toEqual({ text: "abcdef", truncated: false });
+154
View File
@@ -0,0 +1,154 @@
// Format Docs tests cover the docs formatter helper process spawning.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
chunkFilesForCommand,
docsFiles,
formatDocs,
resolveOxfmtInvocation,
runOxfmt,
} from "../../scripts/format-docs.mjs";
import { createScriptTestHarness } from "./test-helpers.js";
const { createTempDir } = createScriptTestHarness();
function writeDocsFixture(root: string): void {
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# OpenClaw\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "guide.mdx"), "# Guide\n", "utf8");
}
describe("format-docs", () => {
it("wraps the Windows oxfmt.cmd shim through cmd.exe", () => {
const invocation = resolveOxfmtInvocation(["--write", "docs\\guide.mdx"], {
comSpec: "C:\\Windows\\System32\\cmd.exe",
existsSync: (candidate: string) => candidate.endsWith("oxfmt.cmd"),
platform: "win32",
repoRoot: "C:\\repo",
});
expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
expect(invocation.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(invocation.args[3]).toContain("oxfmt.cmd");
expect(invocation.args[3]).toContain("--write");
expect(invocation.args[3]).toContain("docs\\guide.mdx");
expect(invocation.shell).toBe(false);
expect(invocation.windowsVerbatimArguments).toBe(true);
});
it("batches oxfmt invocations when docs exceed the command line budget", () => {
const root = createTempDir("openclaw-format-docs-batch-");
const calls: Array<{ args: string[]; command: string }> = [];
runOxfmt(
["docs/one.md", "docs/two.md", "docs/three.md"],
{
maxCommandLineBytes: 1,
repoRoot: root,
},
{
existsSync: () => false,
spawnSync: (command: string, args: string[]) => {
calls.push({ args, command });
return { status: 0, stderr: "", stdout: "" };
},
},
);
expect(calls).toHaveLength(3);
expect(calls.every((call) => call.command === process.execPath)).toBe(true);
expect(calls.map((call) => call.args.at(-1))).toEqual([
"docs/one.md",
"docs/two.md",
"docs/three.md",
]);
});
it("reports git and oxfmt spawn diagnostics", () => {
const root = createTempDir("openclaw-format-docs-failures-");
expect(() =>
docsFiles(root, {
spawnSync: () => ({
status: 128,
stderr: "fatal: not a git repository",
stdout: "",
}),
}),
).toThrow(/git ls-files failed:[\s\S]*exit status: 128[\s\S]*fatal: not a git repository/u);
expect(() =>
runOxfmt(
["README.md"],
{ repoRoot: root },
{
existsSync: () => false,
spawnSync: () => ({
status: 1,
stderr: "formatter stderr",
stdout: "formatter stdout",
}),
},
),
).toThrow(
/oxfmt failed:[\s\S]*command:[\s\S]*exit status: 1[\s\S]*formatter stderr[\s\S]*formatter stdout/u,
);
});
it("uses repository paths in write mode and temporary paths in check mode", () => {
const root = createTempDir("openclaw-format-docs-mode-");
writeDocsFixture(root);
const oxfmtFileArgs: string[][] = [];
const spawnSync = (command: string, args: string[]) => {
if (command === "git") {
return {
status: 0,
stderr: "",
stdout: "README.md\ndocs/guide.mdx\n",
};
}
oxfmtFileArgs.push(args.slice(-2));
return { status: 0, stderr: "", stdout: "" };
};
expect(
formatDocs(
{
check: false,
repoRoot: root,
root,
},
{
existsSync: fs.existsSync,
spawnSync,
},
),
).toEqual({ changed: [], fileCount: 2 });
expect(
formatDocs(
{
check: true,
repoRoot: root,
root,
},
{
existsSync: fs.existsSync,
spawnSync,
},
),
).toEqual({ changed: [], fileCount: 2 });
expect(oxfmtFileArgs[0]).toEqual(["README.md", "docs/guide.mdx"]);
expect(oxfmtFileArgs[1]?.every((filePath) => path.isAbsolute(filePath))).toBe(true);
expect(oxfmtFileArgs[1]?.every((filePath) => filePath.startsWith(root))).toBe(false);
});
it("keeps single oversized docs in their own command chunk", () => {
expect(chunkFilesForCommand(["docs/very-long-name.md"], ["--write"], 1)).toEqual([
["docs/very-long-name.md"],
]);
});
});
@@ -42,6 +42,10 @@ import { parseArgs as parseLinuxSmokeArgs } from "../../scripts/e2e/parallels/li
import { parseArgs as parseMacosSmokeArgs } from "../../scripts/e2e/parallels/macos-smoke.ts";
import { parseArgs as parseNpmUpdateSmokeArgs } from "../../scripts/e2e/parallels/npm-update-smoke.ts";
import { PhaseRunner } from "../../scripts/e2e/parallels/phase-runner.ts";
import {
posixCodexPlatformPackageRepairFunction,
windowsCodexPlatformPackageRepairFunction,
} from "../../scripts/e2e/parallels/plugin-isolation.ts";
import { parseArgs as parseWindowsSmokeArgs } from "../../scripts/e2e/parallels/windows-smoke.ts";
import { withEnv } from "../../src/test-utils/env.js";
import { spawnNodeEvalSync } from "../../src/test-utils/node-process.js";
@@ -275,6 +279,20 @@ describe("Parallels smoke model selection", () => {
}
});
it("repairs only the exact missing Codex platform package failure with a fresh npm cache", () => {
const posixRepair = posixCodexPlatformPackageRepairFunction();
const windowsRepair = windowsCodexPlatformPackageRepairFunction();
for (const repair of [posixRepair, windowsRepair]) {
expect(repair).toContain("Missing optional dependency @openai/codex-");
expect(repair).toContain("NPM_CONFIG_CACHE");
expect(repair).toContain("--ignore-scripts");
expect(repair).toContain("codex-platform-repair: managed npm install completed");
}
expect(posixRepair).toContain("repair_missing_codex_platform_package");
expect(windowsRepair).toContain("Repair-MissingCodexPlatformPackage");
});
it("writes full model ids as config map keys in provider batches", () => {
const batch = JSON.parse(modelProviderConfigBatchJson("openai/gpt-5.5", "windows")) as Array<{
path: string;
@@ -94,6 +94,27 @@ describe("scripts/profile-extension-memory", () => {
}
});
it("creates parent directories for nested JSON report paths", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
try {
const extensionDir = path.join(root, "dist", "extensions", "simple");
const reportPath = path.join(root, ".artifacts", "memory", "report.json");
mkdirSync(extensionDir, { recursive: true });
writeFileSync(path.join(extensionDir, "index.js"), `export default {};\n`, "utf8");
const result = runProfileExtensionMemory(
["--extension", "simple", "--skip-combined", "--concurrency", "1", "--json", reportPath],
root,
);
expect(result.status, result.stderr).toBe(0);
const report = JSON.parse(readFileSync(reportPath, "utf8"));
expect(report.counts).toMatchObject({ totalEntries: 1, ok: 1, fail: 0, timeout: 0 });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("fails when a profiled plugin import fails", () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-extension-memory-test-"));
try {
+22 -2
View File
@@ -774,7 +774,7 @@ describe("control UI routing", () => {
expect(thread.scrollTop).toBe(targetScrollTop);
});
it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => {
it("hydrates hash tokens, preserves same-scope URL edits, and reloads after gateway changes", async () => {
const app = mountApp("/ui/overview#token=abc123");
await app.updateComplete;
@@ -799,12 +799,32 @@ describe("control UI routing", () => {
'input[placeholder="ws://100.x.y.z:18789"]',
HTMLInputElement,
);
const sameScopeUrl = `${refreshed.settings.gatewayUrl}/`;
gatewayUrlInput.value = sameScopeUrl;
gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
await refreshed.updateComplete;
expect(refreshed.settings.gatewayUrl).toBe(sameScopeUrl);
expect(refreshed.settings.token).toBe("abc123");
gatewayUrlInput.value = "wss://missing-token.example/openclaw";
gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
await refreshed.updateComplete;
expect(refreshed.settings.gatewayUrl).toBe("wss://missing-token.example/openclaw");
expect(refreshed.settings.token).toBe("");
sessionStorage.setItem(
"openclaw.control.token.v1:wss://other-gateway.example/openclaw",
"other-token",
);
gatewayUrlInput.value = "wss://other-gateway.example/openclaw";
gatewayUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
await refreshed.updateComplete;
expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
expect(refreshed.settings.token).toBe("");
expect(refreshed.settings.token).toBe("other-token");
});
it("keeps a hash token pending until the gateway URL change is confirmed", async () => {
+15
View File
@@ -199,6 +199,21 @@ function loadSessionToken(gatewayUrl: string): string {
}
}
export function resolveGatewayTokenForUrlEdit(
currentGatewayUrl: string,
nextGatewayUrl: string,
currentToken: string,
): string {
if (
normalizeGatewayTokenScope(currentGatewayUrl) === normalizeGatewayTokenScope(nextGatewayUrl)
) {
return currentToken;
}
// Gateway tokens stay session-scoped across endpoint edits.
// Durable settings may contain scrubbed legacy tokens, but must not restore them here.
return loadSessionToken(nextGatewayUrl);
}
function persistSessionToken(gatewayUrl: string, token: string) {
try {
const storage = getSessionStorage();
+103
View File
@@ -1633,6 +1633,109 @@ describe("chat voice controls", () => {
});
});
describe("chat composer IME composition", () => {
it("defers draft sync while IME composition is active", () => {
const onDraftChange = vi.fn();
const onRequestUpdate = vi.fn();
const container = renderChatView({ onDraftChange, onRequestUpdate });
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
) as HTMLTextAreaElement;
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
textarea.value = "dangqian";
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
expect(onDraftChange).not.toHaveBeenCalled();
expect(onRequestUpdate).not.toHaveBeenCalled();
textarea.value = "当前";
textarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
expect(onDraftChange).toHaveBeenCalledTimes(1);
expect(onDraftChange).toHaveBeenLastCalledWith("当前");
});
it("preserves composing text across host rerenders with stale draft props", () => {
const onDraftChange = vi.fn();
const onRequestUpdate = vi.fn();
const container = document.createElement("div");
const props = createChatProps({ draft: "", onDraftChange, onRequestUpdate });
render(renderChat(props), container);
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
) as HTMLTextAreaElement;
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
textarea.value = "dangqian";
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
expect(onDraftChange).not.toHaveBeenCalled();
expect(onRequestUpdate).not.toHaveBeenCalled();
render(renderChat({ ...props, draft: "" }), container);
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("dangqian");
const rerenderedTextarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
) as HTMLTextAreaElement;
rerenderedTextarea.value = "当前";
rerenderedTextarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
expect(onDraftChange).toHaveBeenCalledTimes(1);
expect(onDraftChange).toHaveBeenLastCalledWith("当前");
});
it("leaves keyboard events to the browser while IME composition is active", () => {
const onHistoryKeydown = vi.fn(() => ({
handled: true,
preventDefault: true,
restoreCaret: null,
decision: "handled:history-up" as const,
historyNavigationActiveBefore: false,
historyNavigationActiveAfter: false,
selectionStart: 0,
selectionEnd: 0,
valueLength: 0,
}));
const onSend = vi.fn();
const container = renderChatView({ onHistoryKeydown, onSend });
const textarea = requireElement(
container,
".agent-chat__composer-combobox > textarea",
"composer textarea",
) as HTMLTextAreaElement;
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
textarea.value = "dangqian";
const enterEvent = new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
});
const arrowEvent = new KeyboardEvent("keydown", {
key: "ArrowUp",
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(enterEvent);
textarea.dispatchEvent(arrowEvent);
expect(enterEvent.defaultPrevented).toBe(false);
expect(arrowEvent.defaultPrevented).toBe(false);
expect(onSend).not.toHaveBeenCalled();
expect(onHistoryKeydown).not.toHaveBeenCalled();
});
});
describe("chat slash menu accessibility", () => {
function inputDraft(container: HTMLElement, value: string) {
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
+35 -6
View File
@@ -473,6 +473,7 @@ interface ChatEphemeralState {
searchOpen: boolean;
searchQuery: string;
pinnedExpanded: boolean;
composerComposing: boolean;
historyRenderSessionKey: string | null;
historyRenderMessagesRef: unknown[] | null;
historyRenderMessageCount: number;
@@ -499,6 +500,7 @@ function createChatEphemeralState(): ChatEphemeralState {
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
composerComposing: false,
historyRenderSessionKey: null,
historyRenderMessagesRef: null,
historyRenderMessageCount: 0,
@@ -2229,6 +2231,12 @@ export function renderChat(props: ChatProps) {
};
const handleKeyDown = (e: KeyboardEvent) => {
// IME navigation keys belong to the browser; downstream handlers can
// prevent them or commit the in-progress composition as a host draft.
if (vs.composerComposing || e.isComposing || e.keyCode === 229) {
return;
}
// Slash menu navigation — arg mode
if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) {
const len = vs.slashMenuArgItems.length;
@@ -2336,9 +2344,6 @@ export function renderChat(props: ChatProps) {
// Send on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
if (e.isComposing || e.keyCode === 229) {
return;
}
if (!props.connected) {
return;
}
@@ -2352,16 +2357,36 @@ export function renderChat(props: ChatProps) {
}
};
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
const syncComposerValue = (
target: HTMLTextAreaElement,
options: { forceCommit?: boolean } = {},
) => {
adjustTextareaHeight(target);
draftMirror.value = target.value;
const hostDraftNeeded = isBusy || showAbortableUi || props.queue.length > 0;
if (hostDraftNeeded || target.value.startsWith("/") || hasVisibleSlashMenuState()) {
if (
options.forceCommit ||
hostDraftNeeded ||
target.value.startsWith("/") ||
hasVisibleSlashMenuState()
) {
commitComposerDraft(props, target.value);
}
updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value);
};
const handleInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
if (vs.composerComposing || e.isComposing) {
adjustTextareaHeight(target);
draftMirror.value = target.value;
return;
}
syncComposerValue(target);
};
const handleCompositionEnd = (e: CompositionEvent) => {
vs.composerComposing = false;
syncComposerValue(e.target as HTMLTextAreaElement, { forceCommit: true });
};
const handleBlur = (e: FocusEvent) => {
const target = e.target as HTMLTextAreaElement;
commitComposerDraft(props, target.value);
@@ -2450,6 +2475,10 @@ export function renderChat(props: ChatProps) {
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
@compositionstart=${() => {
vs.composerComposing = true;
}}
@compositionend=${handleCompositionEnd}
@blur=${handleBlur}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
+67 -1
View File
@@ -1,6 +1,8 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { resolveGatewayTokenForUrlEdit } from "../storage.ts";
import {
resolveAuthHintKind,
resolvePairingHint,
@@ -8,6 +10,70 @@ import {
shouldShowPairingHint,
} from "./overview-hints.ts";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveGatewayTokenForUrlEdit", () => {
it("preserves the current token for same normalized gateway endpoint edits", () => {
expect(
resolveGatewayTokenForUrlEdit(
"wss://gateway.example/openclaw",
" wss://gateway.example/openclaw/ ",
"abc123",
),
).toBe("abc123");
});
it("loads a scoped token when the normalized gateway endpoint changes", () => {
vi.stubGlobal("sessionStorage", createStorageMock());
sessionStorage.setItem(
"openclaw.control.token.v1:wss://other-gateway.example/openclaw",
"other-token",
);
expect(
resolveGatewayTokenForUrlEdit(
"wss://gateway.example/openclaw",
"wss://other-gateway.example/openclaw/",
"abc123",
),
).toBe("other-token");
});
it("clears the token when the changed gateway endpoint has no scoped token", () => {
vi.stubGlobal("sessionStorage", createStorageMock());
expect(
resolveGatewayTokenForUrlEdit(
"wss://gateway.example/openclaw",
"wss://other-gateway.example/openclaw",
"abc123",
),
).toBe("");
});
it("does not restore legacy durable tokens when the gateway endpoint changes", () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "wss://other-gateway.example/openclaw",
token: "legacy-durable-token",
}),
);
expect(
resolveGatewayTokenForUrlEdit(
"wss://gateway.example/openclaw",
"wss://other-gateway.example/openclaw",
"abc123",
),
).toBe("");
});
});
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
+6 -2
View File
@@ -6,7 +6,7 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
import { icons } from "../icons.ts";
import type { UiSettings } from "../storage.ts";
import { resolveGatewayTokenForUrlEdit, type UiSettings } from "../storage.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import type {
AttentionItem,
@@ -269,7 +269,11 @@ export function renderOverview(props: OverviewProps) {
props.onSettingsChange({
...props.settings,
gatewayUrl: v,
token: v.trim() === props.settings.gatewayUrl.trim() ? props.settings.token : "",
token: resolveGatewayTokenForUrlEdit(
props.settings.gatewayUrl,
v,
props.settings.token,
),
});
}}
placeholder="ws://100.x.y.z:18789"