diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e87a1fc77d7..6e13fdbb1258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 1796d95f7bd5..86d54b853e6d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -306,6 +306,15 @@ "fps", "screenIndex" ] + }, + "screen_snapshot": { + "label": "screen snapshot", + "detailKeys": [ + "node", + "nodeId", + "screenIndex", + "maxWidth" + ] } } }, diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 070abfb3639a..8e4da6f079ef 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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." + `channels.telegram.dmPolicy` controls direct message access: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 95aa5217908f..5de3abcc661d 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index fcb1f9941236..8090e45dbf2e 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -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. | diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 9ac747f19e76..4a0229fcdd68 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -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" diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 5a16810513f1..77afd689467f 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j type CodexPackageManifest = { dependencies?: Record; devDependencies?: Record; + 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", + ]); }); }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index e7180b53944b..8053f472c78d 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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, }); diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 446c06311f4e..ab3d57ff9bb8 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -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 Body User' }, + { kind: "final" }, + ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); - expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty( - "mentions", - ); + expectMockArgFields(sendMessageFeishuMock, "message send params", { + text: 'plain text Body User', + mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }], + }); }); it("does not attach automatic mentions to card replies", async () => { diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index e4356ef7cf0a..9fffb3a1211f 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -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 } + : {}), }); }, }); diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index aa87de539242..904fe8eb38a6 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -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 Body User 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 Body User 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 Body User', + mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }], + }); + + expect(mockConvertMarkdownTables).toHaveBeenCalledWith( + 'body Body User', + "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 Body User' }, + ], + ], + }, + }), + }, + }); + expect(result).toEqual({ messageId: "om_mentions", chatId: "oc_send" }); + }); + it("extracts text content from interactive card elements", async () => { mockClientGet.mockResolvedValueOnce({ code: 0, diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 95fbf90d6760..45309bf16231 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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, { diff --git a/extensions/imessage/src/monitor/echo-cache.ts b/extensions/imessage/src/monitor/echo-cache.ts index b99f4d7a2bbd..c7d3b1ee564a 100644 --- a/extensions/imessage/src/monitor/echo-cache.ts +++ b/extensions/imessage/src/monitor/echo-cache.ts @@ -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; } diff --git a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts index 910ac5a2a828..34ebad1881f9 100644 --- a/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts +++ b/extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts @@ -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")); diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index 3dbcb3104947..625551296701 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -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)}`); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index cf137e15aed5..f37b6471c97e 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -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({ diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 3760f44aea5f..9a9a850513bf 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -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); diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index c069991478cc..df4a05e8f942 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -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(); export async function closeAllMemoryIndexManagers(): Promise { @@ -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(); - 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> { + ): Promise { 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 { + 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(); + 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: { diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 52f468aa8b69..5a041a979f4c 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -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({ diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 0f04106a7e11..0e2b422c2f22 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1566,6 +1566,9 @@ export class QmdMemoryManager implements MemorySearchManager { } async probeEmbeddingAvailability(): Promise { + if (!qmdUsesVectors(this.qmd.searchMode)) { + return { ok: true, checked: false }; + } const ok = await this.probeVectorAvailability(); return { ok, diff --git a/extensions/ollama/doctor-contract-api.test.ts b/extensions/ollama/doctor-contract-api.test.ts new file mode 100644 index 000000000000..f83a26697dc6 --- /dev/null +++ b/extensions/ollama/doctor-contract-api.test.ts @@ -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["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 | undefined { + return config.models?.providers?.["ollama-cloud"] as Record | 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"); + }); +}); diff --git a/extensions/ollama/doctor-contract-api.ts b/extensions/ollama/doctor-contract-api.ts new file mode 100644 index 000000000000..db610ee157d7 --- /dev/null +++ b/extensions/ollama/doctor-contract-api.ts @@ -0,0 +1 @@ +export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js"; diff --git a/extensions/ollama/src/config-compat.ts b/extensions/ollama/src/config-compat.ts new file mode 100644 index 000000000000..71b8e6d26040 --- /dev/null +++ b/extensions/ollama/src/config-compat.ts @@ -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; + message: string; + match: (value: unknown) => boolean; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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: [] }; +} diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 747857ceba14..16738a4c6553 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -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["mentions"] +>; let stickerVisionRuntimePromise: Promise | undefined; let mediaUnderstandingRuntimePromise: Promise | 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 ` (${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, diff --git a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts index f323569f0de9..25ae9e2bf2d0 100644 --- a/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts +++ b/extensions/telegram/src/bot-message-context.implicit-mention.test-support.ts @@ -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 () => { diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index a73c7d908367..184fb04afbdc 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -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["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" diff --git a/extensions/telegram/src/bot-message-context.sticker-media.test.ts b/extensions/telegram/src/bot-message-context.sticker-media.test.ts index 626d66c98daf..5b1f5c96589c 100644 --- a/extensions/telegram/src/bot-message-context.sticker-media.test.ts +++ b/extensions/telegram/src/bot-message-context.sticker-media.test.ts @@ -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, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 840471d6bcfd..19a6ed99b027 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -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 diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 9961ae23d658..73662e3fbd8d 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -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(); diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index ccfe507fc956..a0feb515159b 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -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 diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index 4a71e9caf99a..72513b7ce335 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -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 diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index 639c1e8296da..60e67ab7b39f 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -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' }`; } diff --git a/scripts/e2e/parallels/plugin-isolation.ts b/scripts/e2e/parallels/plugin-isolation.ts index dbf7da57d103..d2642d3f6996 100644 --- a/scripts/e2e/parallels/plugin-isolation.ts +++ b/scripts/e2e/parallels/plugin-isolation.ts @@ -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; } diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index f6bf8218f0d8..a5baa34379b4 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -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 diff --git a/scripts/format-docs.mjs b/scripts/format-docs.mjs index 7b4ed5c0c7ce..72a70e7d797c 100644 --- a/scripts/format-docs.mjs +++ b/scripts/format-docs.mjs @@ -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(); } diff --git a/scripts/profile-extension-memory.mjs b/scripts/profile-extension-memory.mjs index 1c07301036b1..68c45c02acbb 100644 --- a/scripts/profile-extension-memory.mjs +++ b/scripts/profile-extension-memory.mjs @@ -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}`); diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 77f3fa397483..1c4c6815a859 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -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`); diff --git a/skills/skill-creator/scripts/init_skill.py b/skills/skill-creator/scripts/init_skill.py index 8633fe9e3f2d..05e63e50c95e 100644 --- a/skills/skill-creator/scripts/init_skill.py +++ b/skills/skill-creator/scripts/init_skill.py @@ -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} diff --git a/skills/skill-creator/scripts/test_init_skill.py b/skills/skill-creator/scripts/test_init_skill.py new file mode 100644 index 000000000000..47babdce3185 --- /dev/null +++ b/skills/skill-creator/scripts/test_init_skill.py @@ -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() diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 1446f3f55879..4346979b5fb6 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -108,6 +108,31 @@ async function createHeartbeatAgentsWorkspace() { return workspaceDir; } +async function writeCompletedWorkspaceState(workspaceDir: string): Promise { + 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 { + 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( diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 57b9ff78493a..ca4cc80fafde 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -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: { diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index 0eda81db7052..550ea55f3f58 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -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 = 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> { + 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, key: string): string { const raw = params[key]; if (typeof raw !== "string" || raw.trim().length === 0) { diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index 26c74ce3a287..48bdd7b95ca8 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -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)"), diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 519231a935e0..3380f6c01ace 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -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, diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index f9adf53561e6..5ebe8495537d 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -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 { + 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", diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 3517b4df8ec2..1d48d01ab9ee 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -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 { +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 { - const statePath = resolveWorkspaceStatePath(resolveUserPath(dir)); - return await readWorkspaceSetupState(statePath); +async function readWorkspaceSetupStateForDir( + dir: string, + opts?: { persistLegacyMigration?: boolean }, +): Promise { + 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>; + 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 { @@ -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; diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index abb4a3deadfb..b46e726e1ffa 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -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; 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 () => { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 89b78c637b52..639f7d9c44c0 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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") { diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index c07fa0fe4cc0..de59a38f3cdd 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -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 { diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts index 4287628a53a1..64c91a0e73dd 100644 --- a/src/auto-reply/reply/commands-gating.test.ts +++ b/src/auto-reply/reply/commands-gating.test.ts @@ -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 }, diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 98014ad9c88c..400ec2011c89 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -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({ diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index d1672d0873b8..934ce0fd6680 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -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.", diff --git a/src/auto-reply/reply/reply-operation-run-state.ts b/src/auto-reply/reply/reply-operation-run-state.ts new file mode 100644 index 000000000000..3a978166d66f --- /dev/null +++ b/src/auto-reply/reply/reply-operation-run-state.ts @@ -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]; +} diff --git a/src/channels/inbound-event/context.test.ts b/src/channels/inbound-event/context.test.ts index 1d89b722bc18..316330a37157 100644 --- a/src/channels/inbound-event/context.test.ts +++ b/src/channels/inbound-event/context.test.ts @@ -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: { diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts index 0cd442ca5978..36d86b4107a4 100644 --- a/src/channels/inbound-event/context.ts +++ b/src/channels/inbound-event/context.ts @@ -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, diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index 469acfd939fb..ebc47d010932 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -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" >; diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index fccd9a6554fc..ded5b34b4d98 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -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(run: (dir: string) => Promise): Promise { 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"), + ); + }); }); diff --git a/src/cli/nodes-screen.ts b/src/cli/nodes-screen.ts index 70f242d85d4b..0c0a4396186f 100644 --- a/src/cli/nodes-screen.ts +++ b/src/cli/nodes-screen.ts @@ -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); +} diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 9deee0bafb6d..d920888991ef 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts index 0b5b7edad9ba..2bb7a5b3d9cd 100644 --- a/src/commands/doctor/shared/legacy-config-core-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -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).workspace === null) { + repaired += 1; + const { workspace: _workspace, ...rest } = agent as Record; + 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 }; diff --git a/src/commands/onboard-skills.test.ts b/src/commands/onboard-skills.test.ts index 905c5e909b50..050da74e197c 100644 --- a/src/commands/onboard-skills.test.ts +++ b/src/commands/onboard-skills.test.ts @@ -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(platform: NodeJS.Platform, fn: () => Promise): Promise { + 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"); + }); + }); }); diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 0b8a00834e22..0db3e5a60907 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -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()); diff --git a/src/cron/isolated-agent/run.meta-error-status.test.ts b/src/cron/isolated-agent/run.meta-error-status.test.ts index e0b7d16870b7..a6b6fb8b980f 100644 --- a/src/cron/isolated-agent/run.meta-error-status.test.ts +++ b/src/cron/isolated-agent/run.meta-error-status.test.ts @@ -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), diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 7fc1b507923d..9e45e4046d8b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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, diff --git a/src/gateway/drain-active-sessions-for-shutdown.test.ts b/src/gateway/drain-active-sessions-for-shutdown.test.ts index a5024cbec3df..1b3ca1a473b1 100644 --- a/src/gateway/drain-active-sessions-for-shutdown.test.ts +++ b/src/gateway/drain-active-sessions-for-shutdown.test.ts @@ -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, diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index b9cd20522fb5..db5feb668359 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -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", diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 19e890452dd1..3c683632e3bd 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -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; diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts index 4847d7032d3d..4aff2470470c 100644 --- a/src/gateway/session-transcript-files.fs.ts +++ b/src/gateway/session-transcript-files.fs.ts @@ -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; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index a11937c3d6eb..9341fe5569b8 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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: { diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index d1067c0d22e4..553a1f281fe6 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -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 | 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(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5057881de09c..76e5a364eeb6 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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) diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 9261f62cb970..ae1d030f519a 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -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( diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index e0f7350b9c51..2220851b3406 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -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): 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 | readonly string[]; +}): Promise { + const requiredPackageNames = new Set(params.requiredPackageNames); + if (requiredPackageNames.size === 0) { + return []; + } + const lockPath = path.join(params.npmRoot, "package-lock.json"); + const parsed = await readJson(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; diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index cab978d49134..b7e8a83dd167 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -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> } }; + }) => ({ + 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", () => { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 69d56f9fe442..1385c1cfdfcb 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -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"); } diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 1f6eefc9b845..095f13a06528 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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; + }; + 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 { 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"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 983e54f6aac0..9fe19f8bb271 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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(); + 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; 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>; + 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, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 4e8aa36c20d1..ebec5fbbdd60 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1956,6 +1956,7 @@ export type PluginPackageInstall = { minHostVersion?: string; expectedIntegrity?: string; allowInvalidConfigRecovery?: boolean; + requiredPlatformPackages?: string[]; }; export type OpenClawPackageStartup = { diff --git a/src/skills/runtime/refresh-state.test.ts b/src/skills/runtime/refresh-state.test.ts new file mode 100644 index 000000000000..ae96d1d895a2 --- /dev/null +++ b/src/skills/runtime/refresh-state.test.ts @@ -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); + }); +}); diff --git a/src/skills/runtime/refresh-state.ts b/src/skills/runtime/refresh-state.ts index dc866d30ec35..ea93101ba099 100644 --- a/src/skills/runtime/refresh-state.ts +++ b/src/skills/runtime/refresh-state.ts @@ -7,7 +7,8 @@ export type SkillsChangeEvent = { const listeners = new Set<(event: SkillsChangeEvent) => void>(); const workspaceVersions = new Map(); -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; } diff --git a/src/skills/runtime/session-snapshot.test.ts b/src/skills/runtime/session-snapshot.test.ts index 91c03d7ef9cc..503700972bd2 100644 --- a/src/skills/runtime/session-snapshot.test.ts +++ b/src/skills/runtime/session-snapshot.test.ts @@ -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; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 7c4d2c5d6011..09017097fd1c 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -17,6 +17,7 @@ "camera_clip", "photos_latest", "screen_record", + "screen_snapshot", "location_get", "notifications_list", "notifications_action", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index 0644aa48559f..2a4e3d39058d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -17,6 +17,7 @@ "camera_clip", "photos_latest", "screen_record", + "screen_snapshot", "location_get", "notifications_list", "notifications_action", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index eee4d56d186d..4103926ef64d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -17,6 +17,7 @@ "camera_clip", "photos_latest", "screen_record", + "screen_snapshot", "location_get", "notifications_list", "notifications_action", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 0fc4157d59b4..1e05bc6e8fac 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index c58522c0e3cd..f0d02148639f 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 8f6302e25aae..516b0cc764eb 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -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, diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 9147487580f0..770b64861dff 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -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 }); diff --git a/test/scripts/format-docs.test.ts b/test/scripts/format-docs.test.ts new file mode 100644 index 000000000000..72193fdc5ad3 --- /dev/null +++ b/test/scripts/format-docs.test.ts @@ -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"], + ]); + }); +}); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index b7b0ec6bdb59..445fce8767cc 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -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; diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts index f208c7f8963f..d4281be90923 100644 --- a/test/scripts/profile-extension-memory.test.ts +++ b/test/scripts/profile-extension-memory.test.ts @@ -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 { diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index bcdbd4106510..22c86ae7e896 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -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 () => { diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index f6f333587a2b..4b0bcf2857fe 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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(); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 0649d3f8d47e..4562e385a853 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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("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("textarea"); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 24865737b0ab..587ff82fd598 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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} diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index 7f1ee3a67f5b..ad9925220626 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -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); diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index 9335e97a9fbe..8d7daf670f13 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -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"